frontend · level 9

Testing Frontend

Vitest, Testing Library, Playwright — and the pyramid that keeps your CI under five minutes.

200 XP

Testing Frontend

The frontend testing pyramid is not the backend testing pyramid. Pure functions hide where you don't expect them; the bugs that bite production live in the wiring between components, network, browser APIs, and user input. The right shape for a UI test suite has lots of integration tests, fewer unit tests, and a thin sliver of e2e on the critical paths.

Analogy

Picture three levels of car safety testing. Unit testing is the dyno: you spin the engine in a lab, measure RPM and fuel flow, prove the cylinders fire correctly. Integration testing is driving the assembled car around a closed track: doors, brakes, transmission, electronics together. E2E testing is delivering it to a real customer who drives it in real traffic, real weather, real potholes. Each layer catches what the layer above can't, but only the integration test actually proves the car is a car. Most car-safety regulators spend most of their time on the closed track — and that's where most frontend bugs live too.

The frontend testing trophy

Kent C. Dodds' updated diagram (2018), now widely used:

          [ static ]      eslint, tsc, type-checks       — fastest, free
        [   unit   ]      pure functions, no DOM         — quick
   [   integration   ]    component + DOM + mocked I/O   — bulk of the suite
        [   e2e    ]      real browser + real backend    — slow, fragile

The integration tier is the largest in count, not the smallest. Here's why:

  • Frontend bugs are usually in the wiring: a component re-renders when it shouldn't, a callback doesn't fire on the right event, a fetch returns and the success-path doesn't display.
  • A unit test of a 4-line utility doesn't catch any of those. An e2e test does, but takes 30 seconds and breaks for unrelated reasons.
  • An integration test that renders the component, lets the user (Testing Library) interact with it, and asserts on the DOM hits the bug and runs in 50 ms.

Aim for ~10–20% unit, ~70–80% integration, ~5–10% e2e. The exact split depends on the codebase.

Vitest is the modern default

Vitest replaced Jest as the default for new projects in roughly 2023:

  • ESM-first (no transformer headaches)
  • Vite-native (uses your existing vite.config.ts for path aliases / plugins)
  • API-compatible with Jest (describe / it / expect / vi.mock work the same)
  • Fast — file-watch reruns only the affected tests
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./vitest.setup.ts"],
    coverage: {
      reporter: ["text", "html"],
      thresholds: { lines: 99, branches: 99 },
    },
  },
});

For Jest-based codebases, the same patterns apply — the migration to Vitest is mostly a config swap.

Testing Library: query the way users do

The single most important convention @testing-library/react enforces:

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

Concretely: query for elements by what the user perceives, not by implementation details.

// ✓ Good — what a screen reader / user sees
screen.getByRole("button", { name: /sign in/i });
screen.getByLabelText("Email");
screen.getByText(/welcome back/i);

// ✗ Bad — couples the test to your CSS / class names
container.querySelector(".btn-primary");
screen.getByTestId("login-button");

data-testid exists as an escape hatch for elements that genuinely have no role / label / text — but the test is a mirror of accessibility. If the only way to find the element is getByTestId, the element is probably hard for assistive tech to find too.

userEvent simulates real keyboard / mouse interaction, including focus changes, key bubbling, and event sequencing. Always prefer it over fireEvent.

import userEvent from "@testing-library/user-event";

const user = userEvent.setup();
await user.type(screen.getByLabelText("Email"), "user@example.com");
await user.click(screen.getByRole("button", { name: "Sign in" }));

Mocking the network

Two strategies, each with a place:

Inline vi.fn for one-off tests where the call is incidental:

global.fetch = vi.fn().mockResolvedValueOnce({
  ok: true,
  json: async () => ({ user: { id: 1, name: "Ada" } }),
});

Mock Service Worker (msw) for any test suite where you simulate a real backend:

import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.get("/api/orders", () =>
    HttpResponse.json([{ id: "ord_1", total: 100 }]),
  ),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

msw intercepts at the network layer so your code paths through fetch / axios / TanStack Query are real. The same handlers work in node (tests) and the browser (Storybook, demo modes).

Playwright for end-to-end

When you need to test the whole stack — real browser, real navigation, real cookies — Playwright is the de-facto choice:

import { test, expect } from "@playwright/test";

test("user can place an order", async ({ page }) => {
  await page.goto("/products/widget");
  await page.getByRole("button", { name: "Add to cart" }).click();
  await page.getByRole("link", { name: "Cart" }).click();
  await page.getByRole("button", { name: "Checkout" }).click();

  await page.getByLabel("Card number").fill("4242424242424242");
  await page.getByLabel("Expiry").fill("12/30");
  await page.getByLabel("CVC").fill("123");
  await page.getByRole("button", { name: "Pay" }).click();

  await expect(page.getByRole("heading", { name: "Thanks!" })).toBeVisible();
});

What Playwright handles for free:

  • Auto-waitsgetByRole retries until the element is visible + actionable. No await sleep(500) calls.
  • Trace viewer — record an entire failing run as a video + DOM snapshots, view it locally. The single best debugging tool in JS testing.
  • Multi-browser — same test runs in Chromium, Firefox, WebKit (Safari).
  • Fixtures — set up auth state once, share across tests.

Reserve e2e for critical user paths — sign up, log in, checkout, key navigation. A test suite of 1,200 e2e tests is a CI bonfire.

Visual regression

Sometimes the bug is "this looks broken" — the layout shifted by 4 px, the colour is wrong, the dark-mode variant got skipped. Standard tests don't catch this; visual regression does.

  • Chromatic — by Storybook's makers; runs your stories in a deterministic browser, flags any pixel diff. The default for design systems.
  • Percy — same idea, BrowserStack-owned; integrates with Cypress, Playwright, Selenium.
  • Playwright snapshots — built-in toHaveScreenshot(), free but harder to manage at scale.

Most product apps don't need visual regression; design systems and component libraries do. Pick the layer that matters.

Coverage targets

A common rule: aim for 99% coverage on shared / utility / domain code, lower (or unenforced) on UI components where the marginal test asserts trivia.

Vitest's coverage.thresholds will fail CI if a PR drops below the threshold:

coverage: {
  thresholds: {
    lines: 99,
    branches: 99,
    functions: 99,
    statements: 99,
  },
}

Coverage is a floor, not a ceiling. Hitting 100% means nothing if the tests assert nothing useful. Branch coverage matters more than line coverage — branches are where the bugs are.

Common bugs

Brittle selectors. Tests that match .btn-primary break on a CSS rename. Use getByRole and accept the test failing legitimately when the role changes.

Async without await. A test that fires a click and asserts on the next line, before React has rendered. findByText retries; getByText doesn't.

Snapshot tests without thought. A 500-line snapshot that nobody reads is a checkbox, not a test. Snapshot the smallest meaningful unit.

E2E that hits production. A test that signs up real users in production, leaves real orders, sends real emails. Use a staging environment with a clear data-reset, or seed an isolated test tenant.

Coverage as a goal. Writing meaningless tests just to hit 99% — checking that getName(user) === user.name. The threshold catches gaps; it doesn't replace thinking about what to test.

Practical checklist

  • Vitest + Testing Library for component / integration tests.
  • Query by getByRole / getByLabelText. data-testid only when nothing else fits.
  • userEvent over fireEvent. findByX for async results.
  • msw to simulate the network for any suite that hits an endpoint twice.
  • Playwright for critical paths only — sign up, log in, checkout, key nav.
  • Visual regression for design systems; usually skip for product apps.
  • Branch coverage threshold on shared code; trust judgment on UI surfaces.
  • Trace viewer (Playwright) is your debugger when CI fails locally-passing tests.

Tools in the wild

6 tools
  • Vitestfree tier

    Vite-native test runner; ESM-first, fast, Jest-API-compatible. The default for new React projects.

    library
  • Renders components and queries them by accessible role / label / text — the way users find them.

    library
  • Playwrightfree tier

    Cross-browser end-to-end framework; auto-waits, trace viewer, video, screenshots. Great DX.

    library
  • Visual regression on every PR. Tracks pixel diffs of your Storybook stories.

    service
  • Intercepts fetch / XHR at the network layer so integration tests use realistic request handlers.

    library
  • Run axe accessibility checks inside Playwright e2e tests; fail the build on a11y regressions.

    library