Week 14: Unit Testing React Apps

Every component you've built since Week 1 has been manually clicked through in a browser to check it works. This week builds an automated safety net instead — tests that catch a broken form or a silently-failing button before a user ever does, and that survive refactors instead of needing to be rewritten every time an implementation detail changes.

Module 9 of 17 Week 14 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write component tests that test behavior, not implementation
  • Simulate real user interaction and mock network requests in tests
  • Test a custom hook in isolation and reason about what's worth covering

1. Vitest Setup

Vitest is a test runner built for Vite projects — same configuration, same fast dev-server-style re-runs, and a Jest-compatible API if you've used Jest before.

terminal
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
vite.config.ts — add a test block
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom', // simulates a browser DOM in Node
    setupFiles: './src/test-setup.ts',
    globals: true,
  },
});
src/test-setup.ts
import '@testing-library/jest-dom'; // adds matchers like .toBeInTheDocument()
a first test — sum.test.ts
import { describe, it, expect } from 'vitest';

function sum(a: number, b: number) {
  return a + b;
}

describe('sum', () => {
  it('adds two numbers', () => {
    expect(sum(2, 3)).toBe(5);
  });
});

jsdom is what makes browser-like testing possible in Node at all — it simulates a DOM well enough that React can render into it and your test can query the result, without ever opening a real browser.

2. React Testing Library's Philosophy

React Testing Library (RTL) is built around one guiding principle, printed on its own homepage: "the more your tests resemble the way your software is used, the more confidence they can give you." In practice, that means testing what a user sees and does — visible text, buttons, form fields — rather than a component's internal state or implementation details.

the wrong way — testing implementation
// Fragile: breaks if you rename the internal state variable,
// even if the component's actual behavior is unchanged.
expect(wrapper.state('isOpen')).toBe(true);
expect(wrapper.find('.internal-class-name')).toHaveLength(1);
the RTL way — testing behavior
// Resilient: this passes as long as the user-visible outcome is correct,
// regardless of how the component achieves it internally.
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Settings saved')).toBeInTheDocument();

This philosophy is why RTL deliberately has no API for reaching into a component's props or internal state — it's a constraint, not a missing feature. A test written this way keeps passing through a component being refactored from useReducer to useState, or from a class to a function, because none of that changes what the user actually sees or can click.

3. Queries & User Events

RTL's queries (getByRole, getByLabelText, getByText...) find elements the way an assistive technology or a real user would identify them — which is also why writing tests this way tends to surface accessibility problems (Week 21) for free.

Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import Counter from './Counter';

describe('Counter', () => {
  it('increments when the button is clicked', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    expect(screen.getByText('Count: 0')).toBeInTheDocument();

    const button = screen.getByRole('button', { name: /increment/i });
    await user.click(button);

    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

getByRole('button', {'{ name: /increment/i }'}) finds the button the same way a screen reader would announce it — by its accessible role and name — not by a CSS class or test-specific attribute. @testing-library/user-event (not the lower-level fireEvent) simulates a real click as a full sequence of browser events (pointer down, focus, pointer up, click), catching bugs a simplified synthetic event would miss.

testing a form — combining Week 5's work
it('shows a validation error for an invalid email', async () => {
  const user = userEvent.setup();
  render(<SignupForm />);

  await user.type(screen.getByLabelText('Email'), 'not-an-email');
  await user.click(screen.getByRole('button', { name: /sign up/i }));

  expect(await screen.findByText(/enter a valid email/i)).toBeInTheDocument();
});

findByText (rather than getByText) returns a promise and retries until the element appears or a timeout elapses — necessary here because React Hook Form's validation (Week 5) runs asynchronously after submission, so the error message isn't in the DOM the instant click resolves.

4. Mocking Network Requests with MSW

A component test shouldn't depend on a real backend being available and returning consistent data. Mock Service Worker (MSW) intercepts actual fetch calls at the network level and returns data you control — your component code doesn't know it's being tested at all.

terminal
npm install -D msw
src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: Number(params.id), name: 'Ada Lovelace' });
  }),
];
src/test-setup.ts — wired into every test
import '@testing-library/jest-dom';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
UserProfile.test.tsx — component uses real fetch, MSW intercepts it
it('shows the fetched user name', async () => {
  render(<UserProfile userId={5} />);
  expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});

This is a meaningfully better approach than mocking your fetchUser function directly — UserProfile runs its actual, real fetch call, and only the network layer underneath it is faked. The test verifies your real request logic and real response handling, not just that a mocked function was called with the right arguments.

5. Testing Custom Hooks & Coverage

A custom hook (Week 4) is just a function, but it can't be called directly in a test the way a plain function can — it uses useState/useEffect internally, which only work inside React's render cycle. Testing Library's renderHook solves this by mounting the hook inside a minimal invisible component for you.

useCounter.ts — the hook under test
import { useState, useCallback } from 'react';

export function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  const reset = useCallback(() => setCount(initial), [initial]);
  return { count, increment, reset };
}
useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('increments and resets', () => {
    const { result } = renderHook(() => useCounter(5));

    expect(result.current.count).toBe(5);

    act(() => result.current.increment());
    expect(result.current.count).toBe(6);

    act(() => result.current.reset());
    expect(result.current.count).toBe(5);
  });
});

act() wraps any code that triggers a state update outside of an event RTL already wraps for you (like calling result.current.increment() directly) — it flushes React's pending updates before the next assertion runs, the same guarantee userEvent gives you for free in component tests.

terminal — coverage report
npx vitest run --coverage

A coverage percentage tells you which lines executed during your test run — it says nothing about whether the right assertions ran against them. 100% coverage with weak assertions (expect(result).toBeTruthy() everywhere) is worse than 70% coverage of your app's actual critical paths — signup, checkout, anything money or auth touches — tested thoroughly.

Coverage as a floor, not a target

A CI coverage threshold (vitest run --coverage --coverage.thresholds.lines=80) is useful for catching an untested file slipping in unnoticed — treat it as a minimum bar, not a goal to chase for its own sake. Week 15 picks this thread back up at a larger scale: integration and end-to-end tests that verify these unit-tested pieces actually work together.

6. Hands-on Exercise

Hands-on

Write a full unit test suite for Week 5's signup form

Cover the form with component tests, then test one custom hook it depends on in isolation — then add the two kinds of tests most suites skip.

Part 1 — Core suite:

  1. Set up Vitest + React Testing Library + jest-dom in a project containing Week 5's signup form.
  2. Write a test confirming the form renders all expected fields with correct labels (getByLabelText for each).
  3. Write a test confirming a validation error appears when submitting with a mismatched password/confirmPassword, using userEvent.type and findByText.
  4. Write a test confirming a successful submission (valid data) shows a success message — mock the "submit" network call with MSW rather than testing against a real endpoint.
  5. If the form uses a custom hook (e.g. a useFormStatus or field-validation hook), test it directly with renderHook, independent of the component that consumes it.
  6. Run vitest run --coverage and confirm the form and its hook are both meaningfully covered — not just executed, but covered by assertions that would actually fail on a real regression.
Hint

If getByLabelText can't find a field, it's almost always because the <label>'s htmlFor doesn't match the input's id — the same accessibility wiring from Week 5's exercise. A failing test here is often catching a real a11y bug, not just a test-configuration problem.

Part 2 — Accessibility regressions, async hooks, and enforced coverage:

  1. Install vitest-axe (or jest-axe) and write a test asserting the form has zero automated accessibility violations both on initial render and after triggering a validation error — a11y regressions often appear only once error markup is present, not on a clean form.
  2. Pick an async custom hook from an earlier week (Week 4's useTodos with its localStorage persistence is a good candidate) and write a renderHook test that uses waitFor to assert the hook's state settles correctly after an async action, not just immediately after calling it.
  3. Write one deliberately tricky test: use userEvent.type to type into the name field character-by-character, then assert the field's final rendered value matches exactly what was typed, in order — a real regression class where a broken onChange handler (e.g. reading stale state) silently drops or reorders characters.
  4. Add coverage thresholds to vitest.config.ts (branches, functions, lines, statements all at 80%), then delete one of your Part 1 tests temporarily and confirm vitest run --coverage now exits with a non-zero status — proof the threshold is actually enforced, not just displayed.
Hint

expect(await axe(container)).toHaveNoViolations() needs the actual rendered DOM container from RTL's render() result, not a re-created element — pass render(...).container straight into axe(), and remember to re-run it after the interaction that produces the error state, not only on the initial render.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does React Testing Library deliberately not provide a way to read a component's internal state directly?

Its guiding principle is testing behavior the way a real user experiences it, not implementation details. Tests that check internal state break whenever that implementation changes — even if the user-visible behavior is identical — while tests written against visible text and roles keep passing through refactors, because they verify the same thing a user would actually notice.

Q2

Why prefer getByRole('button', {'{ name: ... }'}) over something like getByClassName or a test ID?

It finds the element the same way an assistive technology would identify it — by its accessible role and name — which both survives CSS/styling refactors that would break a class-name query, and doubles as an accessibility check: if getByRole can't find your button, a screen reader user likely can't either.

Q3

What does MSW actually intercept, and why is that better than mocking your fetchUser function directly?

It intercepts the actual network request at the network layer, letting your component's real fetchUser code run exactly as it would in production — only the response is faked. Mocking fetchUser itself skips testing that function's real logic entirely, so a bug inside it (a wrong URL, a wrong header) would go completely undetected.

Q4

Why does testing a custom hook require renderHook instead of just calling the hook function directly in a test?

Hooks like useState and useEffect only work inside React's render cycle — calling a custom hook as a plain function outside a component throws, because there's no fiber for React to associate the state with. renderHook mounts the hook inside a minimal invisible test component so it behaves exactly as it would in real usage, while still giving the test direct access to its return value via result.current.