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

Frontend Developer roadmap · Production · 18 min read · https://levelcode.org/roadmaps/frontend/testing-frontend

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

| Level | Tests | Tool | Speed | Confidence |
| --- | --- | --- | --- | --- |
| Unit | A pure function or module in isolation | Vitest | Milliseconds | That the logic is right |
| Component | A component rendered into a simulated DOM, driven like a user would | Vitest + Testing Library | Tens of milliseconds | That the UI behaves correctly |
| End-to-end | The real app in a real browser, against a running server | Playwright | Seconds | That 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:

| Variant | No match | Use it to |
| --- | --- | --- |
| `getBy…` | Throws immediately | Get an element that should be there now |
| `queryBy…` | Returns `null` | Assert 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.

> [!WARNING]
> Never `await new Promise((r) => setTimeout(r, 500))` to "let things load". Fixed waits are slow or flaky; `findBy` and `waitFor` poll until the condition holds.

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

> [!TIP]
> A component that is hard to test is often a design signal. Moving logic into a pure function or passing a dependency in as a prop usually helps both.

## 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>
  )
}
```

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

## Quiz

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

   A. `getByTestId('save-button')`
   B. `container.querySelector('.btn-save')`
   C. `getByRole('button', { name: 'Save' })`
   D. `getByText('Save')`

   **Answer: C.** Role queries match how users and assistive technology perceive the page and verify accessibility at the same time. getByText would also match non-button text, and test ids and CSS selectors are implementation details invisible to users.

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

   A. `expect(await screen.findByRole('list')).toBeInTheDocument()`
   B. `expect(screen.getByRole('list')).toBeInTheDocument()`
   C. `expect(screen.queryByRole('list')).toBeNull()`
   D. `await new Promise((r) => setTimeout(r, 1000))` followed by `getByRole`

   **Answer: A.** findBy queries return a promise that retries until the element appears or the timeout passes. getBy throws immediately because the list is not there yet, queryBy asserts the opposite, and a fixed sleep is slow and flaky.

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

   A. Retries are not supported in jsdom
   B. Without it, failed requests are retried three times with backoff, so error states appear too late and tests time out
   C. It makes queries run synchronously
   D. It disables the cache so each query refetches

   **Answer: B.** The default retry behaviour delays the error state by several seconds, longer than findBy's default timeout. Turning retries off makes error-path tests fast and deterministic. A fresh client per test separately prevents cache leakage.

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

   A. Add `await page.waitForTimeout(3000)` before the click
   B. Retry the whole test until it passes
   C. Switch to a different browser
   D. Use role-based locators and web-first assertions such as `await expect(locator).toBeVisible()`, relying on auto-waiting

   **Answer: D.** Playwright actions auto-wait for elements to be actionable, and expect(locator) assertions retry until they pass. Fixed timeouts make tests slower and still flaky, and blanket retries hide real problems.
