Eagle Black Ltd

Opinionated Guide to React unit testing

This guide hopes to provide a solid foundation for writing high-quality React unit tests. The React documentation and development team provide very little in the way of guidance and instead leave it up to us to decide how best to write unit tests. Which given so many different approaches and tools available for testing React components, it can be overwhelming to know where to start. Every example you find online will be written at a different time and provide different ideas. In this guide, I’ll share my opinionated best practices and tips for writing effective and efficient React unit tests.

Unit testing is an essential part of the software development process and React is no exception. Testing helps ensure that your application is functioning as expected, and it also makes it easier to maintain and scale your codebase over time.

It's important to identify what is the purpose of writing unit tests, and what benefits you hope to achieve by having written them. Having 100% code coverage does not mean your code is bug free, or that the code even works correctly. Unit tests should primarily provide the following benefits:

  • ensure requirements are being met at time of writing
  • prevent regression
  • facilitate refactoring

Without a test suite, it’s dangerous to make major structural changes to a code base. Even minor changes such as updating packages could cause unforeseen bugs that are hard to detect until code is deployed. With good tests developers can become more confident that refactoring won’t introduce bugs, this encourages teams to act to make those structural improvements.

Tools

Jest

Jest is a popular testing framework for JavaScript and React, and it offers a lot of features out of the box, such as automatic mocking, snapshot testing, and parallel test execution.

React Testing Library

React Testing Library is preferred because of its simplicity, accessibility-focused approach, and ability to test the components as a user would interact with them. This leads to more meaningful, less brittle tests. Additionally, it encourages writing tests that avoid testing implementation details and instead focus on testing the behaviour of the components, making the tests more maintainable and less likely to break when changes are made to the code.

Typescript

Using TypeScript for React unit tests provides all the usual benefits:

  • code editors can provide better auto-completion, error checking, and other features, making it easier and faster to write tests.
  • easier to refactor code, catch bugs early, and ensure that code changes don't break existing tests.
  • If the React application being tested is written in TypeScript, using TypeScript for the tests helps ensure consistency and reduces the amount of context switching required when switching between writing application code and writing tests.

ESLint (with Jest plugin)

Enforce team level code standards on your unit tests to keep them consistent across projects and helps improve the quality of the tests by providing sensible default rules

Prettier

As with ESLint provides team wide formatting standards.

VSCode Jest Extension

Provides out of the box support for useful features such as automatically rerunning tests as you make changes, showing test failure messages in line with the code, allowing rerunning of individual tests via the interface.

Best practices

Write clear and descriptive test names:

All test descriptions should be written in a BDD (Behaviour-Driven Development) manner that tries to always mirror the requirements as specified in work items, and not follow the developers implantation in the code. Good test names might follow the "Given-When-Then" format and clearly describe the expected behaviour of the system. Bad test names, on the other hand, tend to be too vague or focused on technical details. Take the following example:

“Test form calls mock API with expected params”

vs

“Given a user has entered their name, when they submit the form, then name is saved against the user.”

Both these tests describe the same scenario but one maintains the developers intent at the time and helps document the code with its purpose.

With better behaviour driven test descriptions it allows you to write better tests that actually reflect the users journey through the site, instead of tying tests to the implementation of the components.

Write tests that reflect how users interact

Rather than writing your test to test the implementation, instead write it how you expect a user to interact with the page, and how the requirements specify the outcomes. With jest this generally involves preferring getting by text or role over test ids and css selectors.

describe('Given an Article component', () => { describe('When clicking read more button', () => { it('call ready more function', async () => { const mockOnReadMore = jest.fn(); render(<Article onReadMore={mockOnReadMore} />); const button = screen.getByTestId('read-more-button') expect(button).toBeInTheDocument(); await userEvent.click(button!) expect(mockOnReadMore)).toHaveBeenCalled(); }); });

vs

describe('Given an Article component', () => { describe('When a user clicks to read more', () => { it('Should display the additional text', async () => { render(<Article />); const button = screen.queryByText('Read More') expect(button).toBeInTheDocument(); await userEvent.click(button!) expect(screen.queryByText('lora ipsum')).toBeVisible(); }); });

Snapshot Testing

Snapshot testing, a feature of jest, can be used with React to capture the html output of a rendered component and store it as a string in a text file. This captured output can then be compared with future runs of the test to see if there are any changes. The negatives of Snapshot testing are it makes it appear code is fully tested, but snapshots do not test the behaviour of the component, only its output.

React team’s advice

It’s typically better to make more specific assertions than to use snapshots. These kinds of tests include implementation details so they break easily, and teams can get desensitized to snapshot breakages. Selectively mocking some child components can help reduce the size of snapshots and keep them readable for the code review.

However, React is a rendering library at the end of the day so it is important that the html it generates is monitored.

Best cases to use snapshot testing:

  • You have a simple component with no state or functionality, who’s single responsibility is rendering, typically an atom in atomic design. If your component is meant to render a list, inspecting the html, and confirming a li is produced can be useful. Given the component is small, it should make the snapshots less brittle to changes.
  • If your component renders a 3rd party component, such as material UI, you might want to confirm it is rendering what you expect it to, and not producing vast amounts of unnecessary elements and classes.
  • Confirming the created HTML is valid and good quality. Much like how you should care how Entity Framework has interpreted your code to write SQL, and the performance implications not using the right index might bring, you should care about the generated HTML.

Things to check for during a PR containing snapshots:

  • Size/amount of nesting, right tags for the job, missing aria attributes, too much or invalid CSS from your css-in-js library.
  • Truly understanding the change an update to a component will make.
  • Ensuring the SSR contains only the HTML you expect to see. As an example, “hidden” from material UI is powerful tool for rendering different content at different breakpoints, but knowing the use case and if you want to use CSS media queries to control hiding instead of js. By checking the snapshot, you will clearly see if the render contains all the elements or not.

Mocking dependencies

Mocking can be a useful tool for testing, but it’s important not to rely too heavily on it. Mocking can make your tests more complex and harder to understand, and it can also hide real issues in your code. Use mocking sparingly and only when it’s necessary.

React team’s advice

How much to mock: With components, the distinction between a “unit” and “integration” test can be blurry. If you’re testing a form, should its test also test the buttons inside of it? Or should a button component have its own test suite? Should refactoring a button ever break the form test?

Coming from a TDD back end background it can be natural to automatically mock all dependencies of your components and test them fully in isolation. This would make the React component the ‘Unit’ under test. While this has its uses, tests end up heavily tied to the implementation of the components, making refactoring harder. React components are not isolated bits of logic, they are normally part of a complex tree of components, with global state, API calls and caching all externally handled. If we attempt to isolate just the react component on its own, we have to understand every dependency of the component. This is far away from testing requirements are the user would see and is instead testing as the developer wrote it. We should strive to test the behaviour not the implementations.

Instead of thinking of your typescript React code as the unit under test, instead adopt the idea that it's a unit of the actual rendered UI.

Mocking Child Components

Because React components typically render child components, by attempting to just test a form component you are actually rendering the form along with all child inputs, labels, buttons etc These can all be being imported from external files or packages, such as using a material UI button. As such they can be mocked as an external dependency to your code, as shown below. This is referred to as shallow mocking.

This can be useful when the child components have a lot of external dependencies, such as calling APIs, and you want to avoid having to set up too many mocks when these children already have their own tests.

jest.mock("./components/button", () => ({ __esModule: true, default: ({ text, onClick }) => ( <button onClick={onClick} data-testid=”mock-button” > TEST BUTTON {text} </div> ), }));

This now leaves you with two options for testing:

  • Asserting your mock was called with expected parameters
import { button } from./components/button” expect(button).toHaveBeenCalledWith({text: “expected text”})
  • querying for the mock button and finding its text
expect(screen.getByTestId(“mock-button”)).toHaveText(“expected text”)

Both examples suffer from ending up testing your mock, tying the test to the code implementation, and not testing the page how the end user will see it.

Most times it ends up being a far more useful test to not mock the child components, and instead just write tests in a manner that allows implementation to change over time, while keeping functionality intact.

e.g. expect(screen.getByRole(“button”).toHaveText(“expected text”)

If at some point the button API changes so it no longer takes “text” as a prop, or we swap out material UI for another library, the test keeps working without modification while still confirming our original requirements.

Side note on performance. Jest has issues with barrelling https://blog.codecentric.de/javascript-test-performance-getting-the-best-out-of-jest

Jest creates a new module registry for each test file and has to crawl through the whole dependency chain of modules which are imported by the test suite, even if they are completely unused. It is easy for developers to overlook this fact given that build tools such as webpack can eliminate dead code via tree-shaking , but this is only the case for production builds and does not help with test execution or development servers.

This makes a very good case for mocking any components loaded from an external package/module.

Pros of shallow mocking in React unit tests:

  • Faster tests as only the component being tested is rendered.
  • Avoids unexpected behaviour from child components.
  • Helps isolate and test specific logic.

Cons of shallow mocking in React unit tests:

  • May not accurately reflect the real behaviour of the component with its child components.
  • The test may not catch issues with how the component interacts with its child components.
  • Shallow mocking can be more complex and time-consuming to set up compared to full rendering.

Mocking global state access

The more your tests resemble the way your software is used, the more confidence they can give you. - Kent C. Dodds – Redux Team

Advice from Redux https://redux.js.org/usage/writing-tests generally boils down to do not mock calls to global state, instead setup your global state and allows test to integration test the component requesting data correctly.

Mocking API Access

Rather than mocking the call to the code that will be calling the api, or the library such as React Query/RTK Query etc, instead mock the fetch response. Using tools such as

Code coverage

Unit test coverage reports provide a quick and easy way to see the level of testing that has been done on your code, allowing you to identify areas where additional tests may be needed to improve code quality and reliability. Unit test coverage reports provide a way to measure progress over time, allowing teams to see if they are making progress towards their testing goals and objectives.

Howeverm unit test coverage reports only show which lines of code have been executed during testing, not whether the tests themselves are correct or thorough. It's possible to have high coverage but still have incorrect or insufficient tests. Teams may focus too heavily on achieving high coverage numbers rather than writing comprehensive, high-quality tests that truly validate the code.

Code coverage has some usage in giving a high level view on how much of your application is covered in tests, but as discussed in the snapshot testing, you can have high levels of coverage with very poor quality tests.

If you decide to set a hard limit of say 80% lines and branch coverage, then over time as the number of commits increases, coverage will tend towards 80%. A better approach is to revaluate coverage metrics PR by PR and ensure the coverage stats are always increasing unless there is a very good reason not to. By setting up and allowing test coverage to ratchet, over time coverage will naturally increase and tend towards 100%. Tools to enable this with jest https://jestjs.io/docs/configuration#coveragethreshold-object and https://www.npmjs.com/package/jest-coverage-thresholds-bumper

TDD Test-driven development

Not too long ago this was the big thing, driven by seminal books such as Uncle Bobs Clean Code, developers were pushed to practice TDD wherever possible as the perfect form of writing code. This advice has now shifted over time, Clean Code might not be the bible it once was (see https://qntm.org/clean)

To quote from “A Philosophy of software design”

The problem with test-driven development is that it focuses attention on getting specific features working, rather than finding the best design…. Test-driven development is too incremental: at any point in time, its tempting to just hack in the next feature to make the next test pass. There’s no obvious time to do design, so its easy to end up with a mess.

Strategies such as Red, Green, Refactor can be a lie. You cannot refactor the code under test other than moving code around a file under test. To do any structural refactoring now you have gained a better understanding of the problem and solution, you must completely rewrite the tests, making your initial TDD at best a waste at worst a hinderance as you now have extra work to do updating tests.

TLDR

  • Write tests in BDD language in the form Given, When, Then
  • Write tests using findByText instead of findByTestId or CSS selectors
  • Avoid Snapshot testing
  • Avoid mocking imports and instead mock boundaries (fetch, global state)
  • Avoid mocking child components unless they cause significant performance problems or require excessive test set up (Children call a lot of APIs etc)
  • Avoid strict code coverage metrics
  • Avoid enforcing TDD