Introduction
With Jest and React Testing Library, React offers some amazing tooling for testing React applications. However, learning how to test properly can still be a challenge, especially for complex React apps, which utilize things like React Router, authentication, data fetching libraries like SWR, etc.
In this post, I'll give a brief introduction to how testing React applications works. I will also provide a few tips and tricks that I've learned and point you to some resources I found useful.
How does testing React apps work?
Tests for React apps can be classified into four categories: End-to-end tests, integration tests, unit tests and static tests. In this article I'll focus on
- Integration tests: Verify that several units (components, functions) work together properly, and
- Unit tests: Verify that individual, isolated parts work as expected.
Check out the article Static vs Unit vs Integration vs E2E Testing for Frontend Apps by Kent C. Dodds, if you want to learn more about this topic.
The good news is, when you create a new React project with create-react-app
, it already comes with a configured and ready-to-go testing setup.
This testing setup consists of two components: Jest and React Testing Library. Let's take a closer look at them one by one.
Jest - JavaScript Testing Framework
Jest is a JavaScript Testing Framework, which discovers and run tests in a Javascript project and reports on coverage. It also provides tools like mock functions and assertions (toBe
, toBeCloseTo
, toEqual
, toHaveProperty
, etc.).
To see how it works, create a new React project.
$ npx create-react-app react-test --use-npm
Create a new file src/Utils.js
with a function we want to test.
// src/Utils.js
export function sum(a, b) {
return a + b;
}
Now, let's create a test for this function. In the same directory, create another file Utils.test.js
. The extension *.text.js
allows Jest to identify and run any test functions within that file.
// src/Utils.test.js
import { sum } from './Utils'
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
})
With that done, start the test runner in a terminal.
$ npm test
You should see something like this:
PASS src/Utils.test.js
PASS src/App.test.js
Test Suites: 2 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.136 s
As you can see, Jest identified two tests in the project, App.test.js
(which has been created automatically by the create-react-app
command), and Utils.test.js
. Both tests passed.
Now, if you make a change to Utils.js
, Jest will automatically detect the change and re-run the tests.
// src/Utils.js
export function sum(a, b) {
return a - b; // - instead of +
}
Jest will now tell you that one test failed, and also provide information of where and why.
PASS src/App.test.js
FAIL src/Utils.test.js
● adds 1 + 2 to equal 3
expect(received).toBe(expected) // Object.is equality
Expected: 3
Received: -1
2 |
3 | test('adds 1 + 2 to equal 3', () => {
> 4 | expect(sum(1, 2)).toBe(3);
| ^
5 | })
at Object. (src/Utils.test.js:4:21)
Change the -
back to +
and as you'll see, the tests run again, and this time they pass. Pretty cool, no?
When developing a React app, I usually have one terminal with npm test
running in the background, so with every change I make, I will always know if any tests have failed.
To understand better how Jest works under the hood, check out this blog post by Kent C. Dodds, the creator of React Testing Library.
React Testing Library
The second component of the testing setup is React Testing Library (RTL). RTL provides utilities for querying the DOM in a similar way a user would find elements on the page. The cool thing about that is that it gives you more confidence in your test suite, as it encourages you to test functionality rather than implementation details. It also makes your test more maintainable, as it allows you to change implementation details without breaking tests.
Have a look at how that's done in the App.test.js
file.
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
So, what happens here? When Jest detects and runs the test function in that file, it first renders the `
Next, it looks for a particular DOM element. It does this by looking for the text /learn react/i
. The pattern /.../i
just means that the search is not case-sensitive. Then, the test expects the element toBeInTheDocument()
.
One question you might ask is when writing tests, how do you know what is currently rendered? For that, you can use the useful screen.debug()
utility, which will display the rendered DOM in the console.
Insert it in the App.test.js
to see how it works.
test('renders learn react link', () => {
render(<App />);
screen.debug(); // new
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
You'll see the full DOM in your terminal with syntax highlighting. This is helpful to understand what's going on and figuring out how to best query for that element you want to test. Neat!
Test functionality instead of implementation details
To be honest, the example you've just seen is pretty basic. So how would you test user interaction (i.e. clicking a button, filling a form, etc.)?
To illustrate this, here is a part of the example from the docs.
test('loads and displays greeting', async () => {
render(<Fetch url="/greeting" />)
fireEvent.click(screen.getByText('Load Greeting'))
await waitFor(() => screen.getByRole('heading'))
expect(screen.getByRole('heading')).toHaveTextContent('hello there')
expect(screen.getByRole('button')).toBeDisabled()
})
This test simulates the user clicking a button with fireEvent.click()
, and then waits for some new element to appear on the screen with await waitFor(() => screen.getByRole('heading'))
. This needs to be async, as the
component is fetching data in the background and then, once the data has been loaded, the UI is updated to display a heading.
Once the heading is shown, we can assert that the heading has a particular text and the button to load the greeting has been disabled.
This example highlights what I mentioned earlier. Rather than focussing on implementation details (like e.g. a certain onClick
handler of a button to be executed), React Testing Library allows to test functionality and user interaction, allowing you to write much more maintainable tests and giving you confidence that your app works as intended.
If you're interested in a more complete and hands-on introduction to testing React applications, check out the Beginners Guide to Testing React tutorial by Johannes Kettmann.
Tips and tricks
Declarative assertions with jest-dom
The @testing-library/jest-dom library provides a set of custom matchers you can use to extend jest. These will make your tests more declarative, clear to read, and easier to maintain.
In a create-react-app
project, jest-dom
is already integrated.
// src\setupTests.js
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
Example:
const button = screen.getByRole('button', {name: /disabled button/i})
// ❌
expect(button.disabled).toBe(true)
// ✅
expect(button).toBeDisabled()
The assertion .toBeDisabled()
provided by jest-dom
is more readable and also provides better error messages.
Create a custom render function
React applications often use global context providers for things like authentication, cookies, routing, etc. Those are often applied in the index.js
and wrap around the
component. As a result, these context providers are not available by default when testing an individual component. A solution for that is to set up a custom render function, as suggested in the React Testing Library docs. Here is an example custom render function with context provides for cookies, router, authentication and a special config for the SWR data fetching library.
// src/test-utils.js
import React from "react";
import { render } from "@testing-library/react";
import { CookiesProvider } from "react-cookie";
import Router from "./Router";
import { AuthUserProvider } from "./AuthUser";
import { SWRConfig } from "swr";
const AllTheProviders = ({ children }) => (
<CookiesProvider>
<Router>
<AuthUserProvider>
<SWRConfig value={{ dedupingInterval: 0 }}>
{children}
</SWRConfig>
</AuthUserProvider>
</Router>
</CookiesProvider>
);
const customRender = (ui, options) =>
render(ui, { wrapper: AllTheProviders, ...options });
export * from "@testing-library/react";
export { customRender as render };
The
wrapper is very helpful when you're using SWR for data fetching. It ensures that each test receives fresh instead of cached data (see this blog post).
Then, in your component, import from test-utils.js
instead of @testing-library/react
.
// my-component.test.js
import { render, fireEvent } from '../test-utils';
// your tests
Mock APIs with Mock Service Worker
It's good practice to not depend on the backend to running properly when testing the frontend. To acchieve that, you can mock the backend API with a the Mock Service Worker library.
Here is an example of how to use MSW in the setupTests.js
file.
// src/setupTests.js
import { rest } from "msw";
import { setupServer } from "msw/node";
import { cache } from "swr";
const stories = [
{
title: "The Road to React",
author: "Robin Wieruch",
},
...
];
const server = setupServer(
rest.get('/api/stories', (req, res, ctx) => {
return res(ctx.json(jobs));
})
);
beforeAll(() => server.listen());
beforeEach(() => cache.clear()); // use this if you're using SWR, see https://mswjs.io/docs/faq#swr
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Now, when you run the tests, React will use the mocked endpoints instead of the real backend for all requests.
When you have loads of endpoints you need to mock, it can be useful to put the handlers in a separate file, e.g. src/mocks/handlers.js
, as demonstrated in this GitHub repo.
Don't update the state of unmounted components
Sometimes, your tests might show a warning saying An update to MyComponent inside a test was not wrapped in act(...).
along with an error that the state of an unmounted component couldn't be updated.
When your tests switch to a different screen or route, this results in components being unmounted. The error will happen when a component that fetches some data asynchronously in a useEffect
side effect is unmounted before the request is resolved. To avoid this, you need to use a cleanup function to set a variable mounted
to false, and only update the state when mounted
is true.
function MyComponent() {
const [loading, setLoading] = useState(true)
useEffect(() => {
let mounted = true
fetch('/someapi')
.then(() => {
if (mounted) {
setLoading(false)
})
})
return function cleanup() {
mounted = false
}
}, [])
return <div>{stories ? <p>loading...</p> : Fetched!!
}
Check out the article React useEffect Cleanup - How and when to use it by Martín Mato to learn more about that topic.
Finally: Learn from the pros
For more tips and tricks on React and testing, check out the blog from Kent C. Dodds, the creator of the React Testing Library. I particularly recommend the article Common mistakes with React Testing Library.
Integrate tests into CI/CD pipeline (Azure DevOps)
Once you got a good test coverage, you probably want to integrate the tests in your deployment pipeline. Here is an example of how to do that in Azure DevOps.
First, add this custom command to your package.json
.
# package.json
{
...
"scripts": {
...
"test:ci": "npm run test -- --watchAll=false --reporters=default --reporters=jest-junit",
},
}
Next, add these two steps in your azure-pipelines.yml
, just before the npm build
command.
# azure-pipelines.yml
- task: Npm@1
displayName: npm test
inputs:
command: 'custom'
workingDir: 'client/'
customCommand: 'run test:ci'
- task: PublishTestResults@2
displayName: 'supply npm test results to pipelines'
condition: succeededOrFailed() # because otherwise we won't know what tests failed
inputs:
testResultsFiles: 'client/junit.xml'
Now, when your pipeline runs, it will run your React tests and publish the results so they can be displayed in Azure DevOps.
Individual pipeline run
Pipeline analytics over time
Wrapping up
Writing good tests for your React app takes a little time up front, but it allows you to implement new features without breaking existing ones and deliver those features to your users faster and more confidently. Happy testing!
If you have any questions or feedback, let me know in the comments below. Many thanks!