Testing React Apps
Beginner → StaffJest · React Testing Library · Vitest · MSW · Playwright — Beginner to Staff Engineer
What this module covers
Everything from writing your firstexpect() to designing a full test strategy for a production codebase. Topics: Jest fundamentals, React Testing Library, async tests, mocking, custom hooks testing, MSW for network mocking, Vitest, accessibility-driven queries, and staff-level patterns (test architecture, CI performance, coverage strategy).The Testing Pyramid
BeginnerWhat to test and how many of each
Click a layer to learn more:
Testing Trophy (Kent C. Dodds variation)
RTL's author argues the pyramid should be a trophy: a small base of static analysis (TypeScript/ESLint), a larger middle of integration tests, fewer unit tests (logic-only), and very few E2E tests. The key insight is that integration tests give the best confidence/cost ratio for UI code.Jest Fundamentals
BeginnerTest runner · matchers · setup · lifecycle
# Create React App (built-in) — just run:
npm test
# Vite / Next.js — install manually:
npm install -D jest @types/jest ts-jest jest-environment-jsdom
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event// jest.config.ts
import type { Config } from 'jest'
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom', // browser-like DOM (vs 'node')
setupFilesAfterEach: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1', // path aliases
'\.(css|scss)$': 'identity-obj-proxy', // CSS modules
},
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80, statements: 80 },
},
}
export default config// jest.setup.ts — runs after test framework is installed
import '@testing-library/jest-dom' // adds .toBeInTheDocument() etc.
// global fetch polyfill (Node < 18)
import 'whatwg-fetch'
// Optional: silence console.error in tests
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
jest.restoreAllMocks()
})Matchers reference
Every matcher has a .not inverse: expect(x).not.toBeNull()
.toBe()expect(2 + 2).toBe(4)
Strict equality (===). Use for primitives.
.toEqual()expect({a:1}).toEqual({a:1})Deep equality. Use for objects/arrays.
.toStrictEqual()expect(obj).toStrictEqual(expected)
Like toEqual but checks undefined properties & class instances.
Mocking
Intermediatejest.fn · jest.mock · jest.spyOn · manual mocks
// jest.fn() creates a mock function that records calls
const onClick = jest.fn()
render(<Button onClick={onClick}>Click me</Button>)
await userEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
expect(onClick).toHaveBeenCalledWith(/* args */)
// Control the return value
const fetchUser = jest.fn().mockResolvedValue({ id: 1, name: 'Alice' })
const fetchUser = jest.fn()
.mockResolvedValueOnce({ id: 1 }) // first call
.mockRejectedValueOnce(new Error()) // second call throws
// Inspect call history
fetchUser.mock.calls // [[arg1, arg2], [arg1], …]
fetchUser.mock.results // [{ type: 'return', value: … }, …]
fetchUser.mock.instances // 'this' context of each callReact Testing Library
Intermediaterender · screen · queries · userEvent · async · accessibility
Core philosophy
Test behaviour, not implementation. RTL deliberately does not expose component internals (state, instance methods). You interact through the DOM the way a user would — find by visible text, ARIA role, or label — and assert on visible output. Tests that survive a refactor are valuable; tests tied to implementation details are a liability.import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// render() mounts the component in jsdom
// screen is a global query object bound to document.body
it('shows greeting', () => {
render(<Greeting name="Alice" />)
// getBy* — throws if not found (use for elements that should exist)
const heading = screen.getByRole('heading', { name: /hello alice/i })
expect(heading).toBeInTheDocument()
// getByText — shortcut when role is irrelevant
expect(screen.getByText(/welcome/i)).toBeVisible()
})
// within() — scope queries to a sub-tree
import { within } from '@testing-library/react'
it('shows items in a list', () => {
render(<TodoList items={['Buy milk', 'Read book']} />)
const list = screen.getByRole('list')
const items = within(list).getAllByRole('listitem')
expect(items).toHaveLength(2)
})Testing Custom Hooks
IntermediaterenderHook · act · async hooks
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('initialises with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('increments', () => {
const { result } = renderHook(() => useCounter(10))
// act() wraps any interaction that triggers state updates
act(() => { result.current.increment() })
expect(result.current.count).toBe(11)
})
it('exposes a reset function', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(5)
})
})MSW — Mock Service Worker
AdvancedIntercept real network requests in tests and the browser
Why MSW beats jest.mock() for APIs
MSW intercepts requests at the network level (Service Worker in browser, Node HTTP in tests) — your code uses realfetch/axios without modification. You can share the same handlers between unit tests, the dev server, and Storybook.npm install -D msw// src/mocks/handlers.ts — define route handlers
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
])
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json() as { name: string }
return HttpResponse.json({ id: 3, ...body }, { status: 201 })
}),
http.get('/api/users/:id', ({ params }) => {
const { id } = params
if (id === '999') return new HttpResponse(null, { status: 404 })
return HttpResponse.json({ id: Number(id), name: 'Alice' })
}),
]// src/mocks/server.ts — Node server for Jest/Vitest
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// jest.setup.ts — wire up the server
import { server } from './src/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers()) // don't leak overrides
afterAll(() => server.close())Vitest
IntermediateJest-compatible · native ESM · Vite-powered · ~10× faster
| Feature | Jest | Vitest |
|---|---|---|
| Ecosystem | Huge — de facto standard | Growing fast, Jest-compatible API |
| Speed | Fast (worker threads) | ⚡ ~10× faster (Vite HMR, native ESM) |
| ESM support | Needs Babel transform | Native out of the box |
| Config | jest.config.ts | vite.config.ts (test: {} section) |
| Watch mode | jest --watch | vitest (interactive, instant) |
| Coverage | istanbul / v8 | v8 or istanbul (same flags) |
| CRA / Next.js | Built-in or easy setup | Works with Next.js via next/jest but Vitest preferred in Vite apps |
| Globals | Opt-in via jest config | Opt-in: globals: true in config |
Snapshot & Accessibility Testing
IntermediateSnapshot tradeoffs · jest-axe · a11y assertions
When snapshots help vs hurt
Snapshots are great for detecting unexpected changes to stable output (e.g. a design system component). They are terrible as a substitute for meaningful assertions — a snapshot that always gets updated isn't testing anything.// Component snapshot
it('renders correctly', () => {
const { container } = render(<Badge variant="success" label="Active" />)
expect(container.firstChild).toMatchSnapshot()
})
// Inline snapshots — stored in the test file itself (diffs are reviewable)
it('renders badge markup', () => {
const { container } = render(<Badge variant="success" label="Active" />)
expect(container.firstChild).toMatchInlineSnapshot(`
<span
class="badge badge-success"
>
Active
</span>
`)
})
// Update snapshots after intentional design change
jest --updateSnapshot # or: jest -u
vitest --update-snapshotsAvoid large HTML snapshots
A 200-line snapshot diff is unreviable. Prefer inline snapshots or specific assertions. If you must snapshot, wrap the component in a stable container so incidental wrapper changes don't fail the test.Advanced Patterns
AdvancedIntegration testing · TDD · test doubles · error boundaries
Test at the feature level
Rather than testing each component in isolation, test a complete user flow through multiple components. This catches integration bugs and is more resilient to refactors.// A full checkout flow test
it('user can add to cart and checkout', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/orders', () => HttpResponse.json({ id: 'ord-123' }, { status: 201 }))
)
render(<App />, { wrapper: AllProviders })
// Navigate to product
await user.click(screen.getByRole('link', { name: /shop/i }))
await screen.findByRole('heading', { name: /products/i })
// Add item to cart
const addBtn = screen.getAllByRole('button', { name: /add to cart/i })[0]
await user.click(addBtn)
expect(screen.getByRole('status')).toHaveTextContent('1 item in cart')
// Checkout
await user.click(screen.getByRole('link', { name: /cart/i }))
await user.click(screen.getByRole('button', { name: /checkout/i }))
await screen.findByText(/order confirmed/i)
expect(screen.getByText(/ord-123/i)).toBeInTheDocument()
})Staff-Level: Test Strategy & CI
Expert / StaffCoverage · architecture · CI performance · test debt
Coverage is a floor, not a target
Aiming for 100% line coverage creates tests that pass trivially (no meaningful assertions) just to hit the number. Set coverage thresholds as a minimum safety net for critical paths, not a vanity metric.// jest.config.ts — differentiated thresholds
coverageThreshold: {
// Global floor — enforced across all files
global: {
statements: 70, branches: 65, functions: 70, lines: 70,
},
// Per-file floor for critical modules
'./src/lib/pricing.ts': {
statements: 95, branches: 90, functions: 95, lines: 95,
},
'./src/lib/auth.ts': {
statements: 95, branches: 90, functions: 95, lines: 95,
},
},
// Exclude from coverage
coveragePathIgnorePatterns: [
'/node_modules/',
'/src/test-utils.tsx', // test infrastructure
'/src/mocks/', // MSW handlers
'\.d\.ts$', // type declarations
'/stories/', // Storybook
],
// Collect only from source (not test files)
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}']Quick Reference
All levelsThe most important things on one screen
RTL golden rules
- • Query by role first, testid last
- • Use
userEventnotfireEvent - •
findBy*for async content - •
queryBy*to assert absence - • Import everything from your custom
test-utils - • Test observable behaviour, never internal state
Jest golden rules
- • One logical assertion per test (readability)
- •
jest.mock()is hoisted — put it at file top - •
jest.resetAllMocks()inafterEach - • Wrap
toThrowargs in an arrow function - • Prefer
toEqualovertoBefor objects - • Use
jest.useFakeTimers()for time-dependent logic
MSW golden rules
- •
server.resetHandlers()inafterEach - • Default handlers for happy path, override per test for errors
- • Set
onUnhandledRequest: 'error'to catch missing mocks - • Share handlers: tests + Storybook + dev server
Staff-level principles
- • Integration > unit for UI code (confidence/cost ratio)
- • Coverage is a floor, not a metric to maximise
- • Shard tests in CI — keep total CI time under 5 min
- • Test at the user-story level for features
- • Treat flaky tests as P1 bugs
- • A test that survives a refactor is a good test
The testing mantra
beginnerThe more your tests resemble the way your software is used, the more confidence they can give you. — Kent C. Dodds
Understanding act()
IntermediateWhy it exists, when React forces you to use it manually
What act() does
React batches state updates and effects.act() tells React: “run all pending state updates, effects, and re-renders now before I make assertions.” RTL's render, userEvent, and waitFor already wrap themselves in act() — you only need it manually when RTL cannot do it for you.// These ALL wrap act() for you — no manual act() needed:
render(<MyComponent />) // ✅
await userEvent.click(button) // ✅
await userEvent.type(input, 'hello') // ✅
await waitFor(() => expect(...)) // ✅
await screen.findByRole('heading') // ✅
// renderHook act() is also handled for sync hooks:
const { result } = renderHook(() => useState(0))
// But state-changing calls need manual act():
act(() => { result.current[1](42) }) // ✅ manual for renderHook mutationsTesting React Router
IntermediateMemoryRouter · route params · navigation · useNavigate
Key rule
Never useBrowserRouter in tests — it depends on the real browser history API. Use MemoryRouter (React Router v5/v6) or createMemoryRouter (v6 data router) instead. Put the router in your custom render wrapper so every test gets it automatically.import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { render, screen } from '@testing-library/react'
// Wrap component in MemoryRouter to provide routing context
function renderWithRouter(
ui: ReactElement,
{ initialEntries = ['/'] } = {}
) {
return render(
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
)
}
it('renders home page by default', () => {
renderWithRouter(
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>
)
expect(screen.getByRole('heading', { name: /home/i })).toBeInTheDocument()
})
it('renders about page at /about', () => {
renderWithRouter(
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
</Routes>,
{ initialEntries: ['/about'] } // start at /about
)
expect(screen.getByRole('heading', { name: /about/i })).toBeInTheDocument()
})TypeScript-Typed Mocks
Intermediatejest.Mocked<T> · jest.mocked() · typing spies · module mocks
jest.mocked() — the correct way (Jest 27.4+)
Before Jest 27.4, you had to cast manually with(fn as jest.Mock). The newer jest.mocked(fn) returns a fully-typed mock with all mockReturnValue, mockResolvedValue etc. — no cast needed.// api.ts
export async function getUser(id: number): Promise<User> {
const res = await fetch('/api/users/' + id)
return res.json()
}
// UserCard.test.ts
import { getUser } from './api'
jest.mock('./api')
// ✘ Old way — loses type safety
;(getUser as jest.Mock).mockResolvedValue({ id: 1, name: 'Alice' })
// ✅ New way — fully typed, IDE autocomplete works
const mockedGetUser = jest.mocked(getUser)
mockedGetUser.mockResolvedValue({ id: 1, name: 'Alice' }) // types enforced!
mockedGetUser.mockRejectedValue(new Error('Not found'))
expect(mockedGetUser).toHaveBeenCalledWith(1)
expect(mockedGetUser).toHaveBeenCalledTimes(1)Testing useEffect
IntermediateSide effects · subscriptions · cleanup · DOM mutations
// Component with a data-fetching effect
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false // cleanup flag for race conditions
setLoading(true)
fetchUser(userId)
.then(u => { if (!cancelled) { setUser(u); setLoading(false) } })
.catch(e => { if (!cancelled) { setError(e.message); setLoading(false) } })
return () => { cancelled = true } // cleanup on unmount / id change
}, [userId])
if (loading) return <div role="progressbar">Loading…</div>
if (error) return <div role="alert">{error}</div>
return <h1>{user?.name}</h1>
}
// Test — MSW intercepts fetch, RTL awaits DOM change
it('fetches and displays user', async () => {
render(<UserProfile userId={1} />)
expect(screen.getByRole('progressbar')).toBeInTheDocument() // loading state
await screen.findByRole('heading', { name: /alice/i }) // data loaded
})
it('shows error when fetch fails', async () => {
server.use(http.get('/api/users/1', () => new HttpResponse(null, { status: 500 })))
render(<UserProfile userId={1} />)
await screen.findByRole('alert') // error state
})waitFor vs findBy — Deep Dive
IntermediateExact differences, when each breaks, polling internals
| API | Returns | Throws if absent? | Async? | Best use |
|---|---|---|---|---|
| getByRole | Element | Yes — immediately | No | Element that must exist synchronously |
| queryByRole | Element | null | No — returns null | No | Assert element is absent |
| findByRole | Promise<El> | Yes — after timeout | Yes | Element that appears asynchronously |
| waitFor | Promise<T> | Yes — after timeout | Yes | Any async assertion (not just queries) |
| waitForElementToBeRemoved | Promise<void> | Yes — after timeout | Yes | Assert element disappears |
findByRole('button') is exactly equivalent to waitFor(() => getByRole('button')). It polls every 50ms for up to 1000ms (configurable). Use it as the shorthand — it reads better.// These two are identical:
const btn = await screen.findByRole('button', { name: /submit/i })
const btn = await waitFor(() => screen.getByRole('button', { name: /submit/i }))
// Configure timeout and interval
await screen.findByText(/loaded/i, {}, { timeout: 3000, interval: 100 })
await waitFor(
() => expect(screen.getByText(/loaded/i)).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
)
// findByRole returns the element — useful for chaining
const input = await screen.findByRole('textbox', { name: /email/i })
expect(input).toHaveValue('alice@example.com')Debugging Common Test Errors
IntermediateThe 8 errors every React developer hits and how to fix them
Warning: An update to X inside a test was not wrapped in act()
A state update happened after your test finished — usually from an unresolved Promise, setTimeout, or subscription.// ✘ Causes the warning — fetch resolves after test ends
it('renders', () => { // no async!
render(<UserList />) // kicks off a fetch
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// test ends here — fetch resolves → setState → warning!
})
// ✅ Fix 1 — make the test async and await the DOM
it('renders', async () => {
render(<UserList />)
await screen.findByRole('listitem') // waits for data + state update
})
// ✅ Fix 2 — if you can't await, suppress the pending update
it('renders loading state only', async () => {
render(<UserList />)
expect(screen.getByRole('progressbar')).toBeInTheDocument()
// wait for outstanding state updates before test exits
await waitFor(() => {})
})Live Playground
All levelsEdit a complete component + test side-by-side
function LoginForm({ onSuccess }) { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [touched, setTouched] = useState({ email: false, password: false }) const emailErr = touched.email && !email.includes('@') ? 'Valid email required' : null const passwordErr = touched.password && password.length < 8 ? 'Min 8 characters' : null const handleSubmit = async (e) => { e.preventDefault() setTouched({ email: true, password: true }) if (emailErr || passwordErr || !email || !password) return setLoading(true) setError(null) await new Promise(r => setTimeout(r, 600)) // simulate API if (password === 'correct') { onSuccess?.({ email }) } else { setError('Invalid credentials') } setLoading(false) } const field = (label, type, val, onChange, err, name) => ( <div style={{ marginBottom: 14 }}> <label style={{ display: 'block', color: '#9ca3af', fontSize: 12, marginBottom: 4 }}>{label}</label> <input type={type} value={val} onChange={e => onChange(e.target.value)} onBlur={() => setTouched(t => ({ ...t, [name]: true }))} aria-label={label} style={{ width: '100%', padding: '8px 10px', borderRadius: 6, fontSize: 13, background: '#111827', color: '#f9fafb', boxSizing: 'border-box', border: err ? '1px solid #ef4444' : '1px solid #374151' }} /> {err && <p role="alert" style={{ color: '#f87171', fontSize: 11, marginTop: 3 }}>{err}</p>} </div> ) return ( <form onSubmit={handleSubmit} style={{ fontFamily: 'system-ui', maxWidth: 320, padding: 20 }}> <h2 style={{ color: '#f9fafb', marginBottom: 16 }}>Sign in</h2> {field('Email address', 'email', email, setEmail, emailErr, 'email')} {field('Password', 'password', password, setPassword, passwordErr, 'password')} {error && ( <div role="alert" style={{ color: '#f87171', background: '#7f1d1d22', border: '1px solid #7f1d1d', borderRadius: 6, padding: '8px 12px', fontSize: 12, marginBottom: 12 }}> {error} </div> )} <button type="submit" disabled={loading} style={{ width: '100%', padding: '9px 0', borderRadius: 6, border: 'none', background: loading ? '#374151' : '#4f46e5', color: '#fff', fontWeight: 600, cursor: loading ? 'not-allowed' : 'pointer', fontSize: 14 }}> {loading ? 'Signing in…' : 'Sign in'} </button> <p style={{ color: '#6b7280', fontSize: 11, marginTop: 12 }}> Test password: <code style={{ color: '#86efac' }}>correct</code> succeeds, anything else fails. </p> </form> ) } render(<LoginForm onSuccess={d => alert('Logged in as ' + d.email)} />)
// What the tests for this component look like:
import { render, screen, waitFor } from '@/test-utils'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
function setup(props = {}) {
const user = userEvent.setup()
const onSuccess = jest.fn()
render(<LoginForm onSuccess={onSuccess} {...props} />)
return {
user,
onSuccess,
emailInput: screen.getByRole('textbox', { name: /email address/i }),
passwordInput: screen.getByLabelText(/password/i),
submitBtn: screen.getByRole('button', { name: /sign in/i }),
}
}
it('renders the form', () => {
setup()
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
})
it('shows validation errors on empty submit', async () => {
const { user, submitBtn } = setup()
await user.click(submitBtn)
const alerts = screen.getAllByRole('alert')
expect(alerts[0]).toHaveTextContent(/valid email/i)
expect(alerts[1]).toHaveTextContent(/min 8/i)
})
it('calls onSuccess with email after correct password', async () => {
const { user, onSuccess, emailInput, passwordInput, submitBtn } = setup()
await user.type(emailInput, 'alice@example.com')
await user.type(passwordInput, 'correct') // 7 chars — fails! must be >= 8
await user.type(passwordInput, 'x') // now 'correctx' = 8 chars
await user.click(submitBtn)
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith({ email: 'alice@example.com' }))
})
it('shows error alert for wrong password', async () => {
const { user, emailInput, passwordInput, submitBtn } = setup()
await user.type(emailInput, 'alice@example.com')
await user.type(passwordInput, 'wrongpassword')
await user.click(submitBtn)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(/invalid credentials/i)
})
it('disables button while loading', async () => {
const { user, emailInput, passwordInput, submitBtn } = setup()
await user.type(emailInput, 'alice@example.com')
await user.type(passwordInput, 'correctxx') // 9 chars ✓
await user.click(submitBtn)
expect(submitBtn).toBeDisabled() // loading state
await waitFor(() => expect(submitBtn).not.toBeDisabled())
})
})Top 25 Interview Questions
All levelsWith concise answers — beginner to staff level