Tips and Tricks on Testing React Applications

July 22, 2021 • JavaScript, React
Cover

Photo by Quino Al on Unsplash

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

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 ` component, just like the browser would when a user interacts with your app.

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 Individual build

Pipeline analytics over time Test analytics

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!