Step 21 of 21 · optional

Testing the frontend

Write fast unit tests with Vitest, user-centred component tests with Testing Library and MSW, and end-to-end tests with Playwright.

18 min 8 sections

Tests let you change code without fear. Without them, every refactor or upgrade means clicking through the app by hand and hoping. This lesson covers the three levels of frontend tests, the tool for each (Vitest, Testing Library and Playwright) and how to write tests that catch real bugs instead of breaking whenever you rename a variable.

Three levels of test

LevelTestsToolSpeedConfidence
UnitA pure function or module in isolationVitestMillisecondsThat the logic is right
ComponentA component rendered into a simulated DOM, driven like a user wouldVitest + Testing LibraryTens of millisecondsThat the UI behaves correctly
End-to-endThe real app in a real browser, against a running serverPlaywrightSecondsThat whole user journeys work

Higher levels give more confidence per test but run slower. A healthy suite has many unit and component tests and a few end-to-end tests for journeys that must never break, such as sign-up and checkout.

One principle applies at every level: test behaviour, not implementation. Assert on what a user or caller can observe (text on screen, a return value, a request sent), not on internal state or CSS class names. Such tests survive refactoring and fail only when something is actually broken.

Unit tests with Vitest

Vitest runs tests using your Vite configuration, so TypeScript, JSX and path aliases just work. Install it with npm install -D vitest, add "test": "vitest" to your scripts, and put test files next to the code they test, named *.test.ts or *.test.tsx.

Suppose src/cart.ts exports cartTotal(lines, discountPercent = 0), which adds up priceCents * quantity for each line, applies a percentage discount and throws a RangeError for discounts over 100%. Its tests:

ts
// src/cart.test.ts
import { describe, expect, it } from 'vitest'
import { cartTotal } from './cart'

describe('cartTotal', () => {
  it('sums price times quantity', () => {
    expect(cartTotal([{ priceCents: 500, quantity: 2 }, { priceCents: 250, quantity: 1 }])).toBe(1250)
  })

  it('applies a percentage discount', () => {
    expect(cartTotal([{ priceCents: 1000, quantity: 1 }], 15)).toBe(850)
  })

  it('rejects discounts over 100%', () => {
    expect(() => cartTotal([], 120)).toThrow(RangeError)
  })
})
text
$ npx vitest run

 ✓ src/cart.test.ts (3 tests) 2ms

 Test Files  1 passed (1)
      Tests  3 passed (3)

vitest alone starts watch mode, re-running affected tests as you save; vitest run runs once, as in CI. The most common matchers are toBe (same value, using Object.is), toEqual (deep equality for objects and arrays), toThrow, and toHaveBeenCalledWith for mock functions made with vi.fn(). Always test edge cases such as invalid input; that is where bugs live.

Component tests with Testing Library

Component tests need a DOM, which jsdom simulates, plus Testing Library to render and query components:

bash
npm install -D jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
ts
// vite.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'], // leave e2e/ to Playwright
  },
})
ts
// src/test/setup.ts
import '@testing-library/jest-dom/vitest' // adds matchers like toBeInTheDocument
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => cleanup()) // unmount between tests

Now test a component the way a user would: find things by what they see, interact, check the result.

tsx
// src/FruitFilter.tsx
import { useState } from 'react'

export function FruitFilter({ fruits }: { fruits: string[] }) {
  const [query, setQuery] = useState('')
  const visible = fruits.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
  return (
    <>
      <label htmlFor="q">Filter</label>
      <input id="q" value={query} onChange={(e) => setQuery(e.target.value)} />
      {visible.length > 0 ? (
        <ul>{visible.map((f) => <li key={f}>{f}</li>)}</ul>
      ) : (
        <p>No matches</p>
      )}
    </>
  )
}
tsx
// src/FruitFilter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, it } from 'vitest'
import { FruitFilter } from './FruitFilter'

it('filters the list as the user types', async () => {
  const user = userEvent.setup()
  render(<FruitFilter fruits={['Apple', 'Banana', 'Cherry']} />)

  expect(screen.getAllByRole('listitem')).toHaveLength(3)

  await user.type(screen.getByLabelText('Filter'), 'an')
  expect(screen.getAllByRole('listitem').map((li) => li.textContent)).toEqual(['Banana'])

  await user.type(screen.getByLabelText('Filter'), 'x') // the query is now "anx"
  expect(screen.getByText('No matches')).toBeInTheDocument()
  expect(screen.queryByRole('list')).not.toBeInTheDocument()
})

userEvent simulates real interactions (focus, key presses, input events) one character at a time. Create it with userEvent.setup() and await its methods.

Choosing queries

Prefer queries that reflect how users find things, in this order: getByRole (with { name }), getByLabelText, getByPlaceholderText, getByText, and only as a last resort getByTestId. Role queries also check accessibility: getByRole('button', { name: 'Save' }) fails if your "button" is a div.

Each query comes in three variants:

VariantNo matchUse it to
getBy…Throws immediatelyGet an element that should be there now
queryBy…Returns nullAssert that something is not there
findBy…Returns a promise that retries until a timeout (1 s by default)Wait for something that appears asynchronously

Each also has an All form, such as getAllBy…, returning an array.

Async UI and the network

Components that fetch data need two things in tests: control over what the "server" returns, and a way to wait for the result. Do not mock fetch or your own modules. Install Mock Service Worker (npm install -D msw), which intercepts real requests at the network level, so your component's actual fetching code runs:

tsx
// src/TodoList.test.tsx
import type { ReactElement } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, expect, it } from 'vitest'
import { TodoList } from './TodoList' // fetches https://api.example.com/todos with useQuery

const server = setupServer(
  http.get('https://api.example.com/todos', () =>
    HttpResponse.json([{ id: 1, title: 'Write tests' }]),
  ),
)

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

function renderWithClient(ui: ReactElement) {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
  return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}

it('shows todos from the API', async () => {
  renderWithClient(<TodoList />)
  expect(screen.getByText('Loading…')).toBeInTheDocument()
  expect(await screen.findByText('Write tests')).toBeInTheDocument()
})

it('shows an error when the request fails', async () => {
  server.use(
    http.get('https://api.example.com/todos', () => new HttpResponse(null, { status: 500 })),
  )
  renderWithClient(<TodoList />)
  expect(await screen.findByRole('alert')).toHaveTextContent('Could not load todos')
})

Each test gets a fresh QueryClient, so no cached data leaks between tests, and retry: false makes failures surface at once instead of after three retries with backoff, which would exceed findBy's timeout. server.use overrides a handler for one test; resetHandlers undoes it. The URL is absolute because Node's fetch has no page origin to resolve relative URLs against.

End-to-end tests with Playwright

Playwright drives real Chromium, Firefox and WebKit browsers against your running app. Set it up with:

bash
npm init playwright@latest

Point it at your dev server so it starts the app itself:

ts
// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'retain-on-failure',
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
  },
})
ts
// e2e/todos.spec.ts
import { expect, test } from '@playwright/test'

test('a user can add a todo', async ({ page }) => {
  await page.goto('/')
  await page.getByLabel('New todo').fill('Buy milk')
  await page.getByRole('button', { name: 'Add' }).click()

  await expect(page.getByRole('listitem').filter({ hasText: 'Buy milk' })).toBeVisible()
})

The locators mirror Testing Library's. Actions auto-wait: click() waits until the element is visible, enabled and not covered. expect(locator) assertions are web-first: they retry until the condition holds or the timeout expires. Each test also gets a fresh browser context, so cookies and storage never leak between tests.

Run npx playwright test for the suite, or add --ui for an interactive runner with time-travel debugging. When a test fails in CI, the recorded trace shows every action, request and DOM snapshot; open it with npx playwright show-trace.

Keeping tests trustworthy

A suite is only useful if red means "something is broken" and green means "it works". These habits keep it that way:

  • Don't test implementation details. Asserting on component state or className="active" breaks on harmless refactors.
  • Mock at the boundaries. Fake the network (MSW) and time (vi.useFakeTimers()), not your own modules. Every mock is a place where test and reality can disagree.
  • Isolate tests. Each test sets up its own data and must pass alone or in any order. Shared mutable state is the main cause of "passes locally, fails in CI".
  • Always await user actions, findBy queries and Playwright calls, or a test can finish before checking anything.
  • Be wary of snapshots. Large snapshots get updated without being read. Prefer a few specific assertions.
  • Watch a new test fail once. Break the code on purpose and confirm it goes red; a test that cannot fail protects nothing.

Practice

Write component tests for this form. Cover three cases: submitting an empty field shows "Enter a code" and does not call onApply; a valid code calls onApply with the trimmed value and shows "Coupon applied"; a rejected code shows "That code is not valid".

tsx
// src/CouponForm.tsx
import { useState, type FormEvent } from 'react'

type Props = { onApply: (code: string) => Promise<boolean> }

export function CouponForm({ onApply }: Props) {
  const [code, setCode] = useState('')
  const [message, setMessage] = useState('')

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const trimmed = code.trim()
    if (!trimmed) {
      setMessage('Enter a code')
      return
    }
    const ok = await onApply(trimmed)
    setMessage(ok ? 'Coupon applied' : 'That code is not valid')
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="coupon">Coupon code</label>
      <input id="coupon" value={code} onChange={(e) => setCode(e.target.value)} />
      <button type="submit">Apply</button>
      <p role="status">{message}</p>
    </form>
  )
}
Show solution
tsx
// src/CouponForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { CouponForm } from './CouponForm'

function setup(result = true) {
  const onApply = vi.fn<(code: string) => Promise<boolean>>().mockResolvedValue(result)
  const user = userEvent.setup()
  render(<CouponForm onApply={onApply} />)
  return { user, onApply }
}

describe('CouponForm', () => {
  it('asks for a code when the field is empty', async () => {
    const { user, onApply } = setup()
    await user.click(screen.getByRole('button', { name: 'Apply' }))

    expect(screen.getByRole('status')).toHaveTextContent('Enter a code')
    expect(onApply).not.toHaveBeenCalled()
  })

  it('applies a valid code', async () => {
    const { user, onApply } = setup(true)
    await user.type(screen.getByLabelText('Coupon code'), '  SPRING25 ')
    await user.click(screen.getByRole('button', { name: 'Apply' }))

    expect(await screen.findByText('Coupon applied')).toBeInTheDocument()
    expect(onApply).toHaveBeenCalledExactlyOnceWith('SPRING25')
  })

  it('reports a rejected code', async () => {
    const { user } = setup(false)
    await user.type(screen.getByLabelText('Coupon code'), 'EXPIRED{Enter}')
    expect(await screen.findByText('That code is not valid')).toBeInTheDocument()
  })
})

The empty case uses getByRole directly because the message is set synchronously; the others use findByText because the message appears only after the onApply promise resolves. The last test submits with Enter, proving the form works from the keyboard. vi.fn<...>() creates a typed mock, and mockResolvedValue makes it resolve like a real API call.

Key takeaways

  • Use many fast unit and component tests and a few end-to-end tests for critical journeys.
  • Test behaviour users can observe, not implementation details, so tests survive refactoring.
  • Query by role and label first; use getBy for present elements, queryBy for absent ones and findBy for async ones.
  • Mock the network with MSW, give each test a fresh QueryClient with retries off, and never use fixed sleeps.
  • Playwright's auto-waiting actions and web-first assertions make end-to-end tests stable.
  • A test you have never seen fail may not be testing anything.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Which Testing Library query should you generally prefer for finding a Save button?

  2. 2.A list appears only after a mocked request resolves. Which assertion waits for it correctly?

  3. 3.Why do tests for components using TanStack Query usually create a QueryClient with `retry: false`?

  4. 4.A Playwright test sometimes fails because it clicks before the page is ready. What is the best fix?

Finished reading?

Mark it complete to fill in your roadmap.