🧪

Testing React Apps

Beginner → Staff

Jest · React Testing Library · Vitest · MSW · Playwright — Beginner to Staff Engineer

What this module covers

Everything from writing your first expect() 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

Beginner

What to test and how many of each

Testing pyramid — click a layerLive Demo

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

Beginner

Test 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()

Interactive matcher explorerLive Demo
.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

Intermediate

jest.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 call
🏛️

React Testing Library

Intermediate

render · 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

Intermediate

renderHook · 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

Advanced

Intercept 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 real fetch/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

Intermediate

Jest-compatible · native ESM · Vite-powered · ~10× faster

FeatureJestVitest
EcosystemHuge — de facto standardGrowing fast, Jest-compatible API
SpeedFast (worker threads)⚡ ~10× faster (Vite HMR, native ESM)
ESM supportNeeds Babel transformNative out of the box
Configjest.config.tsvite.config.ts (test: {} section)
Watch modejest --watchvitest (interactive, instant)
Coverageistanbul / v8v8 or istanbul (same flags)
CRA / Next.jsBuilt-in or easy setupWorks with Next.js via next/jest but Vitest preferred in Vite apps
GlobalsOpt-in via jest configOpt-in: globals: true in config
📸

Snapshot & Accessibility Testing

Intermediate

Snapshot 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-snapshots

Avoid 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

Advanced

Integration 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 / Staff

Coverage · 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 levels

The most important things on one screen

RTL golden rules

  • • Query by role first, testid last
  • • Use userEvent not fireEvent
  • 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() in afterEach
  • • Wrap toThrow args in an arrow function
  • • Prefer toEqual over toBe for objects
  • • Use jest.useFakeTimers() for time-dependent logic

MSW golden rules

  • server.resetHandlers() in afterEach
  • • 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

beginner

The more your tests resemble the way your software is used, the more confidence they can give you. — Kent C. Dodds

🎬

Understanding act()

Intermediate

Why 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 mutations
🗺️

Testing React Router

Intermediate

MemoryRouter · route params · navigation · useNavigate

Key rule

Never use BrowserRouter 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

Intermediate

jest.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

Intermediate

Side 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

Intermediate

Exact differences, when each breaks, polling internals

APIReturnsThrows if absent?Async?Best use
getByRoleElementYes — immediatelyNoElement that must exist synchronously
queryByRoleElement | nullNo — returns nullNoAssert element is absent
findByRolePromise<El>Yes — after timeoutYesElement that appears asynchronously
waitForPromise<T>Yes — after timeoutYesAny async assertion (not just queries)
waitForElementToBeRemovedPromise<void>Yes — after timeoutYesAssert 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

Intermediate

The 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 levels

Edit a complete component + test side-by-side

The editors below show a complete login form and what the corresponding tests look like. Edit the component and mentally trace how each test maps to it.
login-form.jsx — full component playground
LIVE
editor
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)} />)
preview
// 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 levels

With concise answers — beginner to staff level