Displaying all GoT books
This guide is about writing code that uses the Fetch API in React and TypeScript and how to write unit tests for it. I'll show how make an application that loads all Game of Thrones books from a rest endpoint and displays the book titles. I'm not going to bother building an endpoint like that, we can use this one:
https://www.anapioficeandfire.com/api/
Setting things up
Generate a React/TypeScript project with the following steps (You need npm that comes along when installing node.js and you can get npx by running npm i -g npx
):
Run
npx create-react-app usetestfetch --template typescript
Run the following commands to start the server:
cd usetestfetch
npm start
First clean the App.tsx up to:
import React from 'react';
const App = () => {
return (
<div className="App">
</div>
);
};
export default App;
We need to define what a book is. For now it only needs to have a name:
interface Book {
name: string
}
You could put every field that is returned from the anapioficeandfire.com API in the interface, but in this example I am only going to display the name.
Let's add state for the books that we will display. Since we are using a functional component instead of a class component we need to use the useState hook for this. If you have no experience with state in React you might want to read up the official documentation first.
Below I defined state that holds an array of books, and display the state in the render method.
import React, {useState} from 'react';
interface Book {
name: string
}
const App = () => {
const [books, setBooks] = useState<Book[]>([]);
return (
<div className="App">
{books.map((book, index) => {
const indexToDisplay = index += 1;
return <div key={`book${index}`}>{indexToDisplay} {book.name}</div>
})}
</div>
);
};
export default App;
We render a React node for every entry in our Book[] array using the .map() function function. If you would run this it would still not render anything, because the state is initialized with an empty array []
.
Using the Fetch API
Let's add a function called getBooks that uses the Fetch API to do a GET request on https://www.anapioficeandfire.com/api/books to retrieve the books:
import React, {useState} from 'react';
......
const App = () => {
const [books, setBooks] = useState<Book[]>([]);
const fetchBooks = async () => {
const response: Response = await fetch(
'https://www.anapioficeandfire.com/api/books',
{headers: {'Content-Type': 'application/json'}, method: "GET"}
);
setBooks(await response.json());
};
return (
..........
);
};
export default App;
I decided to make the fetchBooks function async to be able to use await statements instead of handling promises with onfulfilled functions. Now we need to add a way to actually call the fetchBooks function. Let's simply add a button in our render function:
<div className="App">
<button onClick={fetchBooks}>Get GoT books</button>
{books.map((book, index) => {
const indexToDisplay = index += 1;
return <div key={`book${index}`}>{indexToDisplay} {book.name}</div>
})}
</div>
Now run it with npm start
, click the button and see if the titles of all Game of Thrones books are listed nicely like below:
Testing this code!
I went with React Testing Library to render the components and obtain elements. I picked Jest for doing assertions.
My test in App.test.tsx looks like:
import React from 'react';
import {fireEvent, render, waitForElement} from '@testing-library/react';
import App from './App';
describe('Test books api', () => {
test('Verify if books are retrieved on button click', async () => {
// Render the App
const {getByText} = render(<App/>);
// Find the button to retrieve the books
const button = getByText('Get GoT books');
expect(button).toBeInTheDocument();
// Actually click the button.
fireEvent.click(button);
// The above statement will result in an async action, so we need to wait
// a bit before the books will appear:
const book1 = await waitForElement(
() => getByText('1 A Game of Thrones')
);
expect(book1).toBeInTheDocument();
});
});
You can run the test with npm test
. I prefer to run tests via IntelliJ as it gives "run test" options next to the test:
Mocking the Fetch API
Now this test sometimes succeeds and sometimes doesn't. Why? The test actually goes to do a GET request to anapioficeandfire.com. This makes our test depend on a stable internet connection. The default timeout for waitForElement is 4500 seconds, which can be adjusted but that isn't desired here.
I want this to be a unit test that can be run as fast as possible. I want to run it after every commit or even after every code change locally. This way I can find out as fast as possible if my code changes break any tests. I only want my tests to fail because of changes in my own code, not due to anapioficeandfire.com being slow or offline temporarily. If the latter happens, I can't fix it anyway.
I tried a couple of ways to mock network requests and I found 'fetch-mock' the easiest to use. Install it by running: npm install fetch-mock @types/fetch-mock node-fetch
Besides adding the import statement to our App.test.tsx file, (import fetchMock from "fetch-mock";
) you should add some code in the describe block to clear the mock to avoid that tests will affect each other:
afterEach(() => {
fetchMock.restore();
}
You should add a statement that tells fetch-mock which calls should be mocked before the code in your actual test() function:
const books = [
{name: 'A Game of Thrones'},
{name: 'A Clash of Kings'},
{name: 'A Storm of Swords'},
{name: 'The Hedge Knight'},
{name: 'A Feast for Crows'},
{name: 'The Sworn Sword'},
{name: 'The Mystery Knight'},
{name: 'A Dance with Dragons'},
{name: 'The Princess and the Queen'},
{name: 'The Rogue Prince'}
];
fetchMock.mock('https://www.anapioficeandfire.com/api/books', {
body: books,
status: 200
});
The test now always succeeds and runs way faster:
Testing the error scenario
Now what happens if the fetch request somehow fails. The most common situation is that the network is not available. We can easily simulate this by disabling our ethernet adapters.
End users will not even notice that this occurs. They just witness a broken button and think your website sucks.
To prevent this we should add error handling to our code. Define state for an error message. If everything goes well, we render the titles of the book. When an error occurs, we fill the errorMessage state and render it:
const App = () => {
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const [books, setBooks] = useState<Book[]>([]);
const fetchBooks = async () => {
try {
const response: Response = await fetch(
'https://www.anapioficeandfire.com/api/books',
{headers: {'Content-Type': 'application/json'}, method: "GET"});
setBooks(await response.json());
setErrorMessage(undefined);
} catch (cause) {
setErrorMessage('We were unable not retrieve any books due to connection problems. Please check your internet connection.');
}
};
const displayBooks = () => {
return (
<div>
{books.map((book, index) => {
const indexToDisplay = index += 1;
return <div key={`book${index}`}>{indexToDisplay} {book.name}</div>
})}
</div>
);
};
return (
<div className="App">
<button onClick={fetchBooks}>Get GoT books</button>
{errorMessage ? <p>Error: {errorMessage}</p> : displayBooks()}
</div>
);
};
Let's add a test with an error scenario like this:
test('Verify if books are retrieved on button click - error no internet', async () => {
fetchMock.mock(
'https://www.anapioficeandfire.com/api/books',
Promise.reject('TypeError: Failed to fetch')
);
// Render the App
const {getByText} = render(<App/>);
// Find the button to retrieve the books
const button = getByText('Get GoT books');
expect(button).toBeInTheDocument();
fireEvent.click(button);
const errorMessage = await waitForElement(() => getByText('Error: We were unable not retrieve any books due to connection problems. Please check your internet connection.'));
expect(errorMessage).toBeInTheDocument();
});
We didn't cover all cases though! The anapioficeandfire could start returning 400 or 500 HTTP responses. The Fetch API doesn't see these as errors/exceptions, but we can easily build in some validation on the Response object with some if statements:
const fetchBooks = async () => {
try {
const response: Response = await fetch(
'https://www.anapioficeandfire.com/api/books',
{headers: {'Content-Type': 'application/json'}, method: "GET"});
if (response.status === 200) {
setBooks(await response.json());
setErrorMessage(undefined);
} else if (response.status === 404) {
setErrorMessage('The server could not find this page.');
} else {
setErrorMessage('The server did not respond the data we wanted. We apologize for the inconvenience.');
}
} catch (cause) {
setErrorMessage('We were unable not retrieve any books due to connection problems. Please check your internet connection.');
}
};
If you want to manually test this code out locally, you can easily change the url into https://www.anapioficeandfire.com/api/noneexistingpage
to force getting a 404.
Let's see how we can write tests that mock a 404 error:
test('Verify if books are retrieved on button click - error page not found', async () => {
fetchMock.mock('https://www.anapioficeandfire.com/api/books', {
status: 404
});
const {getByText} = render(<App/>);
const button = getByText('Get GoT books');
fireEvent.click(button);
const errorMessage = await waitForElement(() => getByText('Error: The server could not find this page.'));
expect(errorMessage).toBeInTheDocument();
});
That's all for now!
You can find the entire sample repository on GitHub.
Feel free to ask my any questions. Although I'm enthusiastic about React & TypeScript, I haven't used these for a professional project yet. So if you're a veteran on the subject and see bad things in my example please let me know!
Top comments (0)