Week 13: 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 13 Week 13 of 20 ~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
  • Write a basic end-to-end smoke test with Playwright

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 18) 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. End-to-End Tests with Playwright

Component tests (this week, so far) run in a simulated DOM and mock network layer — fast, but they never prove the whole app actually works together in a real browser. An end-to-end (e2e) test drives an actual browser against your actual running app.

terminal
npm init playwright@latest
e2e/signup.spec.ts
import { test, expect } from '@playwright/test';

test('a user can sign up successfully', async ({ page }) => {
  await page.goto('/signup');

  await page.getByLabel('Name').fill('Grace Hopper');
  await page.getByLabel('Email').fill('grace@example.com');
  await page.getByLabel('Password').fill('supersecure123');
  await page.getByLabel('Confirm password').fill('supersecure123');

  await page.getByRole('button', { name: /sign up/i }).click();

  await expect(page.getByText(/welcome, grace/i)).toBeVisible();
});

Notice the same accessible-query philosophy as RTL (getByLabel, getByRole) — Playwright's API was deliberately designed to feel consistent with Testing Library's. The key difference is scope: this test exercises your real router (Weeks 6–7), your real form validation (Week 5), and a real network request to whatever backend the app is actually pointed at — proving the whole system works together, not just one component in isolation.

The testing pyramid, in practice

Write many fast component tests (this week's RTL work) covering individual pieces of behavior, and a small number of e2e tests covering only your most critical user flows (signup, checkout, login) end to end. E2e tests are slower and more brittle to run at scale — reserve them for the paths where "does this actually work in a real browser" matters most.

6. Hands-on Exercise

Hands-on

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

Cover the form with component tests, then add one end-to-end smoke test.

Requirements:

  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. Add one Playwright e2e test that fills out and submits the real form in a real browser, confirming the success message appears.
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.

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

What can a Playwright e2e test verify that an RTL component test cannot?

That the whole system genuinely works together in a real browser — real routing, a real (or real-staging) backend, real network requests, real browser rendering and CSS. An RTL test runs one component in isolation inside a simulated DOM with mocked network calls; it can't catch an integration problem between pieces that only shows up when the full app runs together for real.