Back to Testing
Testing
medium
senior

How would you design a testing strategy for a frontend application?

Pyramid in 2026: many unit tests (pure logic, Vitest/Jest), more integration tests than people expect (component + MSW mocked network, React Testing Library), small E2E suite for top-3 user journeys (Playwright). Test behavior not implementation. Visual regression on key screens. Run the right suite at the right gate — unit pre-push, integration on PR, E2E on main/staging.

9 min read·~20 min to think through

Frontend testing is a portfolio problem, not a single technique. The goals: catch regressions fast (cheap signal), validate user-visible behavior (high-confidence signal), and keep the suite under a budget (low pain to run).

The 2026 stack.

LayerToolWhat it tests
UnitVitest / JestPure functions, hooks, reducers, schemas
Component / integrationReact Testing Library + Vitest + MSWOne screen's behavior with mocked network
End-to-endPlaywrightTop user journeys through a real (or staging) backend
Visual regressionChromatic / Percy / Playwright snapshotsKey screens, dark mode, RTL
Static / lintTypeScript + ESLint + tsc --noEmitType-level invariants

The shape. Not a strict pyramid — closer to the "testing trophy" (Kent C. Dodds): few unit tests, many integration tests, a small E2E layer on top. Integration tests at the component level give the highest confidence per test-second.

Unit

Pure modules — Date helpers, parsers, reducers, business-logic functions. Should be milliseconds each, hundreds in parallel.

ts
test("paginate clamps to last page", () => {
  expect(paginate({ total: 23, perPage: 10, page: 99 })).toEqual({ page: 3, items: [...] });
});

If a unit test mocks more than 2 things, it should probably be an integration test.

Component / integration

Render a component (or a small subtree) and interact with it as a user. Don't assert internal state, render counts, or component instances. Do assert what the user sees and can do.

tsx
test("shows error on bad email", async () => {
  const user = userEvent.setup();
  render(<SignupForm />);
  await user.type(screen.getByLabelText(/email/i), "not-an-email");
  await user.click(screen.getByRole("button", { name: /submit/i }));
  expect(await screen.findByText(/valid email/i)).toBeInTheDocument();
});

MSW (Mock Service Worker) for the network. Define handlers once, share between tests, dev mode, and Storybook:

ts
http.get("/api/users/:id", ({ params }) => HttpResponse.json({ id: params.id, name: "Ada" }));

You're testing the React + fetch + reducer + reconciliation interaction — the integration is the value. Mocking the network at the network layer (not the function layer) keeps tests close to reality.

Anti-patterns.

  • expect(wrapper.state().count).toBe(2) — testing implementation.
  • jest.mock("./useUser") then asserting it was called — testing wiring.
  • Snapshotting whole component trees — brittle to refactor, low value.

Use Testing Library queries by accessibility role / label, in this priority:

  1. getByRole ("button", { name: ... }) — closest to how a screen reader sees the page.
  2. getByLabelText — form fields.
  3. getByText — for static text.
  4. getByTestId — last resort.

If a query is hard to write because the markup isn't accessible, that's a bug worth fixing.

E2E

Playwright (in 2026, dominant over Cypress for new projects — better parallelism, multi-tab, multi-browser, Apple-silicon-friendly).

Keep the suite small: 5–20 tests covering the top user journeys. Don't try to E2E every form — they're slow, flaky, and brittle.

ts
test("user can sign up and create their first project", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("link", { name: "Sign up" }).click();
  await page.getByLabel("Email").fill("ada@ex.com");
  await page.getByLabel("Password").fill("hunter2");
  await page.getByRole("button", { name: "Create account" }).click();
  await expect(page).toHaveURL("/dashboard");
  await page.getByRole("button", { name: "New project" }).click();
  await page.getByLabel("Project name").fill("first");
  await page.getByRole("button", { name: "Create" }).click();
  await expect(page.getByRole("heading", { name: "first" })).toBeVisible();
});

Flake reduction. Use Playwright's auto-waiting (expect(locator).toBeVisible()), not arbitrary sleep. Reset state between tests via API setup, not UI clicks. Run against a deterministic backend (seeded test DB or VCR-style replay).

Visual regression

Chromatic / Percy / Playwright's screenshot mode. Use for: design system primitives, key screens, dark mode, RTL, mobile breakpoints. Not for: every component (signal-to-noise ratio drops).

Where to run what

GateSuiteBudget
Pre-commit / pre-pushUnit + changed integration30s
PR (CI)Full unit + integration5 min
Main → stagingAdd E2E15 min
NightlyCross-browser, visual regressionhours

Coverage as a smell

100% line coverage tells you very little. A function with 100% coverage but no assertions is useless. Mutation testing (Stryker) is the better metric — does the suite catch when you change the code? Expensive to run; useful as a yearly audit.

What NOT to test

  • Third-party libraries (you're testing them, not your app).
  • Trivial getters / type-only code.
  • Framework internals (React's render cycle).
  • Snapshots of large component trees.

Senior framing

The interviewer is checking: can the candidate (1) pick the right layer for a given check, (2) keep the suite fast and stable, (3) treat tests as a product with a budget, (4) test through the user's lens (Testing Library philosophy). The candidate who says "we have 80% coverage" is mid; the one who says "we have 23 E2E tests covering the top-5 journeys, integration is the middle, and unit is for the libraries we own" is senior.

Follow-up questions

  • Why integration > unit for components?
  • When does mocking become a code smell?
  • How do you keep E2E tests from being flaky?
  • Why is mutation testing more meaningful than line coverage?

Common mistakes

  • Asserting on component state instead of user-visible output.
  • Heavy snapshot testing — brittle, low signal.
  • Mocking functions instead of mocking the network (MSW).
  • Building a large E2E suite that's slow and flaky.

Performance considerations

  • Parallelize Vitest / Jest across CPU cores.
  • Playwright sharding across CI runners.
  • MSW is faster than spinning up a real server for integration tests.

Edge cases

  • Time-sensitive logic — `vi.useFakeTimers()` for deterministic time.
  • Async + portals + animations — Testing Library's `findBy` retries; avoid bare `getBy` for async UI.
  • WebSocket flows in E2E — use a deterministic mock server or recorded fixtures.

Real-world examples

  • Most modern OSS React projects: Vitest + RTL + Playwright + MSW.
  • Storybook + Chromatic for component-level visual regression at design-system scale.

Related questions