Testing API Calls With React Testing Library and Jest
Ever since Hooks were released in React, we've been using the useEffect
hook to fetch data, whether directly or abstracted away behind Redux Thunks. Figuring out how to test those scenarios can be really frustrating at first, but fortunately it ends up not being very complicated. I'll show you how to test useEffect
with both successful and failed API calls.
Prerequisites
Goals
- Set up a very simple React app with testing using Jest and React Testing Library
- Write a test for when the API call succeeds
- Write a test for when the API call fails
Setting up the Application and Test Environment
Feel free to skip this part if you want to get right to the good stuff. As I wrote this article, I decided to start with absolutely nothing to see what the bare minimum I could get away with. I wanted all the config files, setup, and modules to get a React environment up and running that outputs a running application and runs tests, with the most up-to-date versions of everything.
I know Vite and Rome and Rollup and lord knows what else are all the rage right now - I'm just using a simple webpack setup because it still works and I care more about just showing the tests in this article. However, please leave a comment to enlighten me on some of the improvements they bring to the table!
So here's the quick application setup.
File structure
What I ended up with looked like this:
.
├── dist
├── node_modules
├── src
│ ├── App.js
│ └── index.js
├── tests
│ └── App.test.js
├── .babelrc
├── jest.config.js
├── package.json
├── setupJest.js
└── webpack.config.js
I won't force you to go on the whole journey as I did, figuring out what was needed, but I'll cut right to the end and let you know all the packages.
Required packages
For the application, React + React DOM was necessary, as well as a few Babel packages.
npm i \
react \
react-dom \
@babel/preset-env \
@babel/preset-react
For setup, bundling, and compilation, webpack, webpack CLI, and a Babel loader were necessary.
npm i \
webpack \
webpack-cli \
babel-loader
And for testing, Jest, JSDom, and React Testing Library were necessary. I also brought in a Jest Mock package because it makes life easier.
npm i -D \
@testing-library/jest-dom \
@testing-library/react \
jest \
jest-environment-jsdom \
jest-fetch-mock
So these are all the packages necessary to get an environment up and running that spits out an application and tests it. Of course, there are some additional quality of life improvements you'd want, like the webpack serve dev server for hot reloads, but it's not necessary.
Config files
Babel
Of course, there's the Babel config file, the same one you've probably been using for years.
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
webpack
And the webpack config file. It makes most of the decisions by default, such as using index.js
as an entry point and outputting to a dist
folder. I just needed to add a module to tell it to use babel-loader
.
module.exports = {
mode: 'production',
module: {
rules: [{ test: /\.js$/, use: ['babel-loader'] }],
},
}
Jest
As for the Jest config file, I just needed it to use jsdom
and set the right directories.
module.exports = {
testEnvironment: 'jsdom',
rootDir: '.',
modulePaths: ['<rootDir>'],
moduleDirectories: ['node_modules', 'src'],
setupFilesAfterEnv: ['<rootDir>/setupJest.js'],
}
Finally, in setupJest.js
, we just want to enable jest-fetch-mock
and import the Jest DOM.
require('jest-fetch-mock').enableMocks()
import '@testing-library/jest-dom'
Package
Adding a script to package.json
that just runs webpack
allows you to test the build and ensure the application is running. I also added a jest
command for the test. Everything else is just the packages brought in by the commands.
{
"scripts": {
"test": "jest --coverage",
"build": "webpack"
}
}
So that's everything as far as config for both the application and testing, now to set up the simple app.
App files
Not too much has changed as far as the React index file goes. The ReactDOM
import and API is slightly different from the last time I used it, and StrictMode
seems to be the default mode, so I'm just rendering to the #root
and pulling in a component.
import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)
So now comes the code we're going to be testing, App.js
. I'm just going to make this file do some "componentDidMount"-esque fetching of data. I know it's not realistic for this to be done in App.js
, but the way the code will be written will be pretty similar in a production level app, it'll just be somewhere else further down.
I'm going to use JSON Placeholder for the example API, but testing will be the same with your own internal APIs, a wrapper around fetch, and even if you're using Redux or some other state management.
So I'll start off with a title and a message, and start setting up the state we'll use: users
for the data coming in, and error
in case an error gets thrown. I could have added in some loading
state, but I just kept it really simple. It should be easy to figure out testing the loading states after reading this article.
import React, { useState, useEffect } from 'react'
export const App = () => {
const [users, setUsers] = useState(null)
const [error, setError] = useState(null)
return (
<>
<h1>List of Users</h1>
<p>No users found</p>
</>
)
}
Now I'll add in the useEffect
that fetches data from the API and updates the data state, otherwise updates the error state.
import React, { useState, useEffect } from 'react'
export const App = () => {
const [users, setUsers] = useState(null)
const [error, setError] = useState(null)
useEffect(() => { const fetchAllUsers = async () => { try { const response = await fetch('https://jsonplaceholder.typicode.com/users') const data = await response.json() setUsers(data) } catch (err) { setError('Something went wrong!') } } fetchAllUsers() }, [])
return (
<>
<h1>List of Users</h1>
<p>No users found</p>
</>
)
}
Finally, I'm just displaying the error if one exists, and displaying the users if they loaded.
import React, { useState, useEffect } from 'react'
export const App = () => {
const [users, setUsers] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
// ...
}, [])
return (
<>
<h1>List of Users</h1>
{error && <div>{error}</div>} {users ? ( <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> ) : ( <p>No users found</p> )} </>
)
}
Okay, now the whole application is complete and we can write the tests. You can build to ensure everything is working properly first.
Build:
npm run build
Output:
> webpack
asset main.js 145 KiB [compared for emit] [minimized] (name: main) 1 related asset
...
webpack 5.75.0 compiled successfully in 4035 ms
I use http-server
in the dist
folder to test the output of an application quickly.
Writing the Tests
You may not have needed all the above context and just want to see the tests. I wrote about it all since it's an up-to-date (for now) example of everything you need to get started, which can be nicer than opening twenty tabs in StackOverflow and seeing answers from 2016.
Now to get started writing the tests. I opted to put them in App.test.js
, but of course there are differing opinions on where tests should live (which I discussed a bit in the React Architecture article). I'm just putting them in a tests
folder for the sake of this example.
To set up, we'll use render
and screen
from the React Testing Library. As implied by the names, render
is responsible for rendering your app to the JS Dom, and screen
allows you to interact with it and see what's there.
I'm putting everything in a describe()
block for App
, and making sure fetchMock
resets between each test.
import React from 'react'
import { render, screen } from '@testing-library/react'
import { App } from 'src/App.js'
describe('App', () => {
beforeEach(() => {
fetchMock.resetMocks()
})
test('renders users when API call succeeds', async () => {})
test('renders error when API call fails', async () => {})
})
When the API call succeeds
First, I'll write the test for when the API call succeeds.
Using fetchMock
, I'll mock the resolved value of the JSON Placeholder /users
API with a list of fake users.
const fakeUsers = [
{ id: 1, name: 'Joe' },
{ id: 2, name: 'Tony' },
]
fetchMock.mockResolvedValue({ status: 200, json: jest.fn(() => fakeUsers) })
Now, what we want is to see what happens after the successful fetch
, which is the users displayed and the "No users found" message not to be there.
This can be done using a combination of waitFor
and getBy
, which uses act
behind the scenes to wait for the event to happen:
await waitFor(() => {
expect(screen.getByText('Joe')).toBeInTheDocument()
})
However, the findBy
query is a combination of waitFor
and getBy
, so we can simplify that even more into a one liner:
expect(await screen.findByText('Joe')).toBeInTheDocument()
So here's our code to mock the fetch
, render the App
, ensure the data is rendered, and ensure nothing we don't want to see is visible:
test('renders users when API call succeeds', async () => {
const fakeUsers = [
{ id: 1, name: 'Joe' },
{ id: 2, name: 'Tony' },
]
fetchMock.mockResolvedValue({ status: 200, json: jest.fn(() => fakeUsers) })
render(<App />)
expect(screen.getByRole('heading')).toHaveTextContent('List of Users')
expect(await screen.findByText('Joe')).toBeInTheDocument()
expect(await screen.findByText('Tony')).toBeInTheDocument()
expect(screen.queryByText('No users found')).not.toBeInTheDocument()
})
When the API call fails
Using what we've learned in the previous test, writing the next test is pretty easy. Instead of resolving a successful API call, we'll have the API throw an error and ensure the error is visible.
test('renders error when API call fails', async () => {
fetchMock.mockReject(() => Promise.reject('API error'))
render(<App />)
expect(await screen.findByText('Something went wrong!')).toBeInTheDocument()
expect(await screen.findByText('No users found')).toBeInTheDocument()
})
Now that both tests are written, we just need to run them.
Running the tests
Using the jest
command, we can run the tests. You can also add the --coverage
flag to see if the tests are catching everything.
npm test
> jest --coverage
PASS tests/App.test.js
App
✓ renders users when API call succeeds (66 ms)
✓ renders error when API call fails (6 ms)
----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 100 | 100 |
App.js | 100 | 100 | 100 | 100 |
----------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.91 s
As we can see, the tests passed with 100% coverage, so we get that green dopamine hit.
Note: I don't necessarily think having 100% test coverage is particularly meaningful, nor that component tests are the most important type of test. Generally, I think end-to-end tests are the most essential, but unit/component tests can be helpful to supplement them.
Conclusion
Well, there you have it. React, React Testing Library, Jest, and Webpack, all working in harmony in (almost) TYOOL 2023. Hopefully this helps someone struggling to figure out how to test useEffect
or get their environment set up!
Comments