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.

.babelrc
{
  "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.

webpack.config.js
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.

jest.config.js
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.

setupJest.js
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.

package.json
{
  "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.

index.js
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.

App.js
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.

App.js
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.

App.js
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.

tests/App.test.js
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:

tests/App.test.js
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.

tests/App.test.js
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