modern-react-spa

Chapter 36

Testing Modern React SPAs — the Full Pyramid

Testing modern React 19.2 SPAs across the pyramid — Vitest, Jest migration, React Testing Library, Playwright, visual regression with Chromatic, and Storybook play functions.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can split tests across the unit / component / integration / e2e / visual pyramid with intent, run Jest (legacy) or Vitest (modern) effectively under React 19.2, drive Playwright for both end-to-end and component testing, plug visual regression into Storybook, reuse Storybook play() functions as the single source of truth for interaction tests, and shard the CI run sanely.

🧭 Prerequisites — Ch 2 (Actions, useOptimistic), Ch 11 (Storybook), Ch 17 (Vite).


🔹 36.1 The testing taxonomy

┌─────────────────────────────────────────────────────────┐
│  Visual (Chromatic / Loki)        — Per story snapshot  │
│  E2E (Playwright)                 — Real browser, full app│
│  Component (Playwright CT / RTL)  — Real component, real CSS│
│  Integration (RTL + MSW)          — Components + faked APIs│
│  Unit (Vitest)                    — Pure logic           │
└─────────────────────────────────────────────────────────┘

Each layer answers a different question:

  • Unit — “is this pure function correct?”
  • Integration — “do these components work together with mocked APIs?”
  • Component — “does this component render right in a real browser?”
  • E2E — “does the user journey work against the real stack?”
  • Visual — “did the design change unexpectedly?”

A healthy pyramid has many unit tests, some integration tests, a few e2e tests, and visual coverage of the design-system components.

⚠️ Don’t write e2e tests for things integration tests would catch. They’re 10× slower and 3× flakier.


🔹 36.2 Jest — the legacy default

https://jestjs.io/ — the default Jest most CRA-era projects ship with. Still works in 2026, with React 19.2 caveats.

Why it’s “legacy” in 2026:

  • No native ESM out of the box — needs Babel transform of every file.
  • Transform overhead means slow startup and slow tests.
  • jest-environment-jsdom is heavier than happy-dom.
  • React 19.2’s compiler interacts with Jest’s transformer; you need careful Babel config to ensure tests see compiled code.

Why you might still use it:

  • Existing suite that works; migration cost outweighs benefit.
  • Plugin ecosystem (Jest snapshot plugins, custom matchers) that hasn’t ported.
  • Team familiarity.

The healthy-Jest checklist:

// jest.config.js (key bits)
{
  "testEnvironment": "happy-dom",                       // 3× faster than jsdom
  "transform": {
    "^.+\\.(t|j)sx?$": ["@swc/jest", { /* … */ }]       // SWC is faster than Babel
  },
  "transformIgnorePatterns": ["node_modules/(?!(some-esm-pkg)/)"],
  "moduleNameMapper": {
    "^@/(.*)$": "<rootDir>/src/$1"
  }
}

SWC instead of Babel saves significant time. happy-dom instead of jsdom saves more.


🔹 36.3 Vitest — the modern default

https://vitest.dev/ — Vite-native, ESM-first, fast.

npm install -D vitest @testing-library/react happy-dom
// vite.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    coverage: { provider: 'v8', reporter: ['text', 'html'] },
  },
});
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';

Vitest’s API is Jest-compatible (describe, it, expect, vi.fn, vi.mock). Most Jest tests run unchanged.

The speed wins:

  • No separate Babel pass — Vite handles transformation.
  • Native ESM — no CJS transform overhead.
  • Watch mode is incredibly fast (sub-second on most repos).
  • Workers parallelise test files.

Vitest 4 — what changed from v2/v3

Vitest 4 is the major you’ll install in a fresh 2026 project. Most of what changed is internal — your test files don’t need to move. The breakage points worth knowing:

  • Workspace config consolidated. The standalone vitest.workspace.ts is deprecated. Define multi-project setups inside vitest.config.ts under test.projects. The old file still works through a compat shim; remove it when convenient.
  • Default pool is forks (was threads). More isolated, slightly slower for trivial tests, much fewer flake reports under happy-dom. Override via test.pool: 'threads' if your suite needs the speed and tolerates the trade-off.
  • v8 coverage is the default provider. c8 still works as an opt-in; istanbul is the slowest of the three but the most accurate.
  • vi.mocked() type behaviour tightened. Code that used vi.mocked(fn) to coerce a function reference may need an explicit cast — TS errors will guide you.
  • Browser mode is GA, not experimental — Playwright-backed alternative to happy-dom when you need real CSS / real focus / real DOM. For most React unit tests, happy-dom is still the right default.

The Jest-compatible surface (vi.fn, vi.spyOn, vi.useFakeTimers, vi.mock) is unchanged in v4. Your migration from Vitest 2 or 3 to 4 is typically a package.json bump and maybe a workspace-config inline.


🔹 36.4 Jest → Vitest migration

Cross-link Ch 21.4 (the CRA migration also covers this). The mechanics:

JestVitest
jest.mock('./x', () => …)vi.mock('./x', () => …)
jest.fn(), jest.spyOn()vi.fn(), vi.spyOn()
jest.useFakeTimers()vi.useFakeTimers()
__mocks__/ auto-loadingManual vi.mock in setup
Custom Jest transformersVite plugins
moduleNameMappervite-tsconfig-paths plugin

The codemod jest-to-vitest handles ~80 % mechanically. Hand-fix:

  • __mocks__ directories (no auto-load in Vitest).
  • Custom transformers (re-implement as Vite plugins).
  • The handful of tests that depend on Jest implementation details.

🔹 36.5 React Testing Library patterns

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from './Button';

describe('Button', () => {
  it('calls onClick when clicked', () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Save</Button>);
    fireEvent.click(screen.getByRole('button', { name: /save/i }));
    expect(onClick).toHaveBeenCalledOnce();
  });
});

Async patterns (React 19.2)

For Actions / useOptimistic flows, RTL’s findBy* queries wait for the element to appear:

it('shows success after action', async () => {
  render(<EditInvoice id="1042" initialAmount={100} />);
  fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '150' } });
  fireEvent.click(screen.getByRole('button', { name: /save/i }));

  await screen.findByRole('status', { name: /saved/i });   // waits
});

Suspense in tests

it('shows fallback then content', async () => {
  render(
    <Suspense fallback={<p>Loading…</p>}>
      <InvoiceDetail id="1042" />
    </Suspense>,
  );
  expect(screen.getByText('Loading…')).toBeInTheDocument();
  await screen.findByText(/INV-1042/);
});

Hooks

import { renderHook, act } from '@testing-library/react';

const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);

🔹 36.6 Playwright

https://playwright.dev/ — the modern e2e standard in 2026.

36.6.1 End-to-end

import { test, expect } from '@playwright/test';

test('login flow', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('shreya@acme.example');
  await page.getByLabel('Password').fill('acme123');
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome, Shreya')).toBeVisible();
});

The killer features:

  • Auto-waitingexpect(...).toBeVisible() polls until ready or timeout.
  • Trace viewernpx playwright show-trace opens a step-by-step timeline of a failed test.
  • Codegennpx playwright codegen acme.example records your clicks as a test skeleton.

36.6.2 Playwright Component Testing

import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test('renders primary variant', async ({ mount }) => {
  const component = await mount(<Button variant="primary">Save</Button>);
  await expect(component).toHaveClass(/primary/);
});

Renders a single component in a real browser — no jsdom approximations. Catches CSS bugs that RTL can’t see.

RTL vs Playwright CT picker:

  • RTL — fast, jsdom, covers most logic.
  • Playwright CT — slower, real browser, catches visual / CSS / focus / animation bugs.

Use both. Most components only need RTL; visual-critical components add a Playwright CT spec.

36.6.3 API mocking with route.fulfill

test('handles API error', async ({ page }) => {
  await page.route('**/api/invoices', (route) =>
    route.fulfill({ status: 500, body: 'Server error' }),
  );
  await page.goto('/invoices');
  await expect(page.getByRole('alert')).toContainText(/could not load/i);
});

Per-test mocking; no global MSW setup needed for one-off cases.

36.6.4 Visual snapshots

test('invoice list matches baseline', async ({ page }) => {
  await page.goto('/invoices');
  await expect(page).toHaveScreenshot('invoices.png', { maxDiffPixels: 100 });
});

First run captures the baseline. Subsequent runs compare. Fails on > 100 differing pixels.

For component-level visual regression, Chromatic is usually a better tool.


🔹 36.7 Visual regression — Chromatic, Loki

Chromatic

https://www.chromatic.com — Storybook-native, hosted, paid.

npx chromatic --project-token=…

On every commit, runs every Storybook story as a snapshot. PR diffs highlight changes. Designers approve in the PR before merge.

The most impactful test layer for design-system work. Catches things tests literally can’t (font rendering, sub-pixel layout, animations).

Loki

https://loki.js.org — self-hosted alternative.

npx loki test

Same idea, on your CI. No Chromatic subscription, more setup.


🔹 36.8 Test data strategy

Factories

// test-factories/invoice.ts
export const makeInvoice = (overrides: Partial<Invoice> = {}): Invoice => ({
  id: 'INV-1042',
  tenant: 'Acme Co.',
  amount: 1290,
  status: 'pending',
  ...overrides,
});

// in tests:
const invoice = makeInvoice({ status: 'overdue' });

Resist the urge to put test data in fixtures files. Factories are cheaper, more readable, and don’t have schema-drift bugs.

MSW for network mocking

https://mswjs.io/ — intercepts fetch at the service-worker level. Same handler can mock dev + tests.

import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/invoices', () => HttpResponse.json([makeInvoice()])),
];
// vitest.setup.ts
import { server } from './msw-server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

One source of truth for “what does the API return?” across your dev tooling and your tests.

Deterministic clocks and randomness

vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-22T12:00:00Z'));
vi.spyOn(Math, 'random').mockReturnValue(0.42);

Tests that fail on Tuesdays at midnight are tests with non-deterministic time. Fake it.


🔹 36.9 Reusing Storybook play() functions in Vitest and Playwright

The cross-link payoff of Ch 11. The same play() function that runs in Storybook can run in your test suite via composeStories:

// Button.test.tsx
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';

const { ClickFiresHandler } = composeStories(stories);

test('Button fires onClick', async () => {
  const { container } = render(<ClickFiresHandler />);
  await ClickFiresHandler.play?.({ canvasElement: container });
});

The same story drives:

  • Storybook (manual visual review).
  • Chromatic (visual regression).
  • Vitest (programmatic test).
  • Playwright (real-browser test).

One source of truth. Less drift. Less duplication.

🔹 36.10 CI orchestration

Sharding Vitest

# Run 1/4 of test files in each shard:
npx vitest --shard=1/4
npx vitest --shard=2/4
npx vitest --shard=3/4
npx vitest --shard=4/4

Parallelise across 4 CI workers; total time roughly divides by 4.

Parallelising Playwright

playwright.config.ts:

export default defineConfig({
  workers: process.env.CI ? 4 : undefined,
  fullyParallel: true,
});

Caching Playwright browsers

- uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: playwright-${{ hashFiles('package-lock.json') }}

Without the cache, every CI run re-downloads ~300 MB of browsers. With it, caches save several minutes per run.


🪤 Common Pitfalls

  1. E2E tests for things integration tests would catch — slow and flaky.
  2. RTL queries by data-testid everywhere — fragile; prefer role / label / text.
  3. act() warnings ignored — they’re real, fix them.
  4. Network calls in unit tests — mock or use MSW.
  5. Snapshot tests of huge component trees — every render changes them; nobody reviews.
  6. Visual regression tests with random / time-dependent content — flake forever.
  7. Playwright e2e tests against shared dev env — flaky on contention; use isolated test infrastructure.

✅ Recap

  • Pyramid: many unit, some integration, few e2e, focused visual.
  • Vitest is the modern default; Jest is the legacy path with a migration codemod.
  • Playwright for e2e and Component Testing.
  • Storybook play() functions reused across Vitest / Playwright is the source-of-truth trick.
  • CI sharding + browser caching are non-optional for sane build times.

🔗 Further Reading

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (10)
  1. 36.1 The testing taxonomy
  2. 36.2 Jest — the legacy default
  3. 36.3 Vitest — the modern default
  4. 36.4 Jest → Vitest migration
  5. 36.5 React Testing Library patterns
  6. 36.6 Playwright
  7. 36.7 Visual regression — Chromatic, Loki
  8. 36.8 Test data strategy
  9. 36.9 Reusing Storybook play() functions in Vitest and Playwright
  10. 36.10 CI orchestration