Testing Frontend
Vitest, Testing Library, Playwright — and the pyramid that keeps your CI under five minutes.
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.tsfor path aliases / plugins) - API-compatible with Jest (
describe/it/expect/vi.mockwork 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-waits —
getByRoleretries until the element is visible + actionable. Noawait 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-testidonly when nothing else fits. userEventoverfireEvent.findByXfor 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- libraryVitestfree tier
Vite-native test runner; ESM-first, fast, Jest-API-compatible. The default for new React projects.
- library@testing-library/reactfree tier
Renders components and queries them by accessible role / label / text — the way users find them.
- libraryPlaywrightfree tier
Cross-browser end-to-end framework; auto-waits, trace viewer, video, screenshots. Great DX.
- service
Visual regression on every PR. Tracks pixel diffs of your Storybook stories.
- libraryMock Service Worker (msw)free tier
Intercepts fetch / XHR at the network layer so integration tests use realistic request handlers.
- library@axe-core/playwrightfree tier
Run axe accessibility checks inside Playwright e2e tests; fail the build on a11y regressions.