Testing Strategies in React

25. May, 2024 7 min read Develop

Four kinds of test, and when each earns its keep

Nobody sets out to write tests. You write them because the third time you break the same checkout flow, someone in the team stops finding it funny. The question is never really whether to test, it's which kind of test to reach for, because they cost wildly different amounts to write and to keep alive.

This post walks through the four layers I use in React projects: unit, integration, end-to-end and screenshot tests. Each section has a working example. I’ve kept the examples small on purpose, because the interesting part is not the syntax, it’s knowing which layer a given bug would have been caught by.

What tests actually buy you

Four things, roughly in order of how much I value them.

They catch bugs while the code is still in your head. Fixing something the same afternoon you wrote it is trivially cheap. Fixing it after a release involves a support ticket, a bisect, and someone’s evening.

They let you refactor. This is the one I’d defend hardest. A component with decent tests can be rewritten without ceremony. A component without them accretes workarounds because nobody dares touch the middle of it.

They document intent. A test named submits the trimmed value tells you something the implementation doesn’t.

And they force a small amount of design discipline, because code that’s painful to test is usually code with too many responsibilities.

Unit tests

A unit test exercises one component or one function on its own. In React that usually means rendering a single component and poking at it.

Two tools do the work. Jest is the test runner: assertions, mocks, spies, snapshots. It came out of Facebook and moved to the OpenJS Foundation in 2022, which is worth knowing mainly because the governance change made the release cadence a lot calmer. React Testing Library is the rendering layer, and its whole philosophy is that you should query the DOM the way a user would, by visible text and accessible role, rather than by class name or component internals.

Here’s a button:

// button.js
import React from 'react';

const Button = ({ onClick, children }) => {
  return <button onClick={onClick}>{children}</button>;
};

export default Button;

And its test:

// button.test.js
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import Button from './button';

test('Button displays the correct text and handles click events', () => {
  const handleClick = jest.fn();
  const { getByText } = render(<Button onClick={handleClick}>Click me</Button>);

  const button = getByText('Click me');
  fireEvent.click(button);

  expect(button).toBeInTheDocument();
  expect(handleClick).toHaveBeenCalledTimes(1);
});

Note that toBeInTheDocument comes from @testing-library/jest-dom, not from Jest itself. It’s in the setup file of every project I’ve worked on, so it’s easy to forget it needs installing at all.

If your project is on Vite, use Vitest instead of Jest. The API is close enough that most test files port over untouched, and it reuses your existing Vite config rather than asking you to maintain a parallel Babel setup. I went through that migration in more about Vite.

Integration tests

Integration tests check that components work together. Same tools, wider scope, which is why the boundary between “unit” and “integration” in a React codebase is mostly a matter of how much you rendered.

// form.js
import React, { useState } from 'react';

const Form = ({ onSubmit }) => {
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = event => {
    event.preventDefault();
    onSubmit(inputValue);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Your name</label>
      <input
        id="name"
        name="name"
        type="text"
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
      />
      <button type="submit">Submit</button>
    </form>
  );
};

export default Form;
// form.test.js
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import Form from './form';

test('Form submits the correct value', () => {
  const handleSubmit = jest.fn();
  const { getByLabelText, getByText } = render(<Form onSubmit={handleSubmit} />);

  const input = getByLabelText('Your name');
  const button = getByText('Submit');

  fireEvent.change(input, { target: { value: 'test value' } });
  fireEvent.click(button);

  expect(handleSubmit).toHaveBeenCalledWith('test value');
});

The htmlFor/id pairing in the component isn’t decoration. getByLabelText resolves through the accessibility tree, so an input without an associated label simply cannot be found by that query. Which is a nice property: if the test can’t find your field, a screen reader probably can’t either.

End-to-end tests

End-to-end tests drive a real browser against a running application. They’re the slowest and the most brittle layer, and they’re also the only one that will ever catch a broken redirect or a misconfigured CSP header.

Playwright is what I use. It’s from Microsoft, runs against Chromium, Firefox and WebKit, and its auto-waiting is good enough that you can mostly stop writing explicit sleeps.

// form.spec.js
const { test, expect } = require('@playwright/test');

test('Form submits the correct value', async ({ page }) => {
  await page.goto('http://localhost:3000/form');

  await page.getByLabel('Your name').fill('test value');
  await page.getByRole('button', { name: 'Submit' }).click();

  await expect(page.getByRole('status')).toHaveText(
    'Submission successful: test value'
  );
});

One thing to watch: toHaveText is a locator assertion, not a page assertion. expect(page) only supports a handful of checks such as toHaveTitle and toHaveURL. Asserting text means pointing at an element first, as above. I’ve seen that mistake in more than one blog post, including an earlier draft of this one.

Keep the number of these tests small and deliberate. Two or three journeys that would embarrass you if they broke in production, not a mirror of your unit test suite.

Screenshot tests

Screenshot testing, or visual regression testing, renders the app, takes a picture, and compares it against a stored baseline. It catches the class of bug no assertion will: the stylesheet change that quietly shifted every card in the grid by four pixels.

Puppeteer is a Node library that drives headless Chrome, and it pairs with jest-image-snapshot for the comparison. Storybook is the other half of a sensible setup, because it lets you capture components in isolation and in specific states rather than fighting a full application into position.

// screenshot.test.js
const puppeteer = require('puppeteer');
const { toMatchImageSnapshot } = require('jest-image-snapshot');

expect.extend({ toMatchImageSnapshot });

describe('Visual Regression Testing', () => {
  let browser;
  let page;

  beforeAll(async () => {
    browser = await puppeteer.launch();
    page = await browser.newPage();
  });

  afterAll(async () => {
    await browser.close();
  });

  it('should match the previous screenshot', async () => {
    await page.goto('http://localhost:3000');
    const screenshot = await page.screenshot();

    expect(screenshot).toMatchImageSnapshot();
  });
});

The expect.extend line is not optional. toMatchImageSnapshot is not a Jest built-in, and without registering it you get a confusing “is not a function” error rather than anything helpful.

Be warned that this layer is where flakiness lives. Font rendering differs between macOS and Linux, animations land mid-frame, and a caret blinking in a text input will fail a test at random. The fix for all three is the same: disable animations, hide the caret, and run the whole thing in a container so the rendering environment is identical everywhere. I went through that setup properly in screenshot testing with React, including the Docker side of it.

What I actually do

  • Write tests that would fail for a reason you care about. A test asserting that a <div> has the class you just gave it is theatre.
  • Name them as sentences. renders the empty state when the list is empty beats test 3.
  • Keep them independent. A suite where test 4 relies on test 2 having run is a suite that will eventually fail in a random order and take a morning to diagnose.
  • Mock at the network boundary, not inside your components. Mocking a component’s internals bakes today’s implementation into the test.
  • Run everything in CI, and run the screenshot tests in the same container image locally that CI uses. Otherwise you spend your life regenerating baselines.

If I had to keep one layer, it would be the integration tests. They catch the most per line of test code, they survive refactoring, and they don’t need a browser farm.

Further reading

The official docs are all good, which is not something you can say about every ecosystem: Jest, Vitest, React Testing Library, Playwright, Puppeteer and Storybook.

Next on my list is trying Playwright’s component testing mode, which sits somewhere between the integration and end-to-end layers and might make one of them redundant. It was still experimental last I checked, so no promises 🙂

‘Till next time!