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-jsdomis 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.tsis deprecated. Define multi-project setups insidevitest.config.tsundertest.projects. The old file still works through a compat shim; remove it when convenient. - Default
poolisforks(wasthreads). More isolated, slightly slower for trivial tests, much fewer flake reports under happy-dom. Override viatest.pool: 'threads'if your suite needs the speed and tolerates the trade-off. v8coverage is the default provider.c8still works as an opt-in;istanbulis the slowest of the three but the most accurate.vi.mocked()type behaviour tightened. Code that usedvi.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:
| Jest | Vitest |
|---|---|
jest.mock('./x', () => …) | vi.mock('./x', () => …) |
jest.fn(), jest.spyOn() | vi.fn(), vi.spyOn() |
jest.useFakeTimers() | vi.useFakeTimers() |
__mocks__/ auto-loading | Manual vi.mock in setup |
| Custom Jest transformers | Vite plugins |
moduleNameMapper | vite-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-waiting —
expect(...).toBeVisible()polls until ready or timeout. - Trace viewer —
npx playwright show-traceopens a step-by-step timeline of a failed test. - Codegen —
npx playwright codegen acme.examplerecords 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
- E2E tests for things integration tests would catch — slow and flaky.
- RTL queries by
data-testideverywhere — fragile; prefer role / label / text. act()warnings ignored — they’re real, fix them.- Network calls in unit tests — mock or use MSW.
- Snapshot tests of huge component trees — every render changes them; nobody reviews.
- Visual regression tests with random / time-dependent content — flake forever.
- 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
- https://vitest.dev/ — Vitest 4 docs.
- https://playwright.dev/ — Playwright docs (pin to 1.49.x at draft time).
- https://testing-library.com/ — Testing Library docs (React + DOM).
- https://mswjs.io/ — MSW v2 docs.
- https://storybook.js.org/docs/api/portable-stories — Storybook 9
composeStoriesdocs. - https://www.chromatic.com/ — visual-regression workflow for Storybook.
- https://loki.js.org/ — self-hosted alternative.
- Ch 11 (Storybook), Ch 21 (Jest→Vitest migration).
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.