Frontend testing strategy
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.
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.
| Layer | Tool | What it tests |
|---|---|---|
| Unit | Vitest / Jest | Pure functions, hooks, reducers, schemas |
| Component / integration | React Testing Library + Vitest + MSW | One screen's behavior with mocked network |
| End-to-end | Playwright | Top user journeys through a real (or staging) backend |
| Visual regression | Chromatic / Percy / Playwright snapshots | Key screens, dark mode, RTL |
| Static / lint | TypeScript + ESLint + tsc --noEmit | Type-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.
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.
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:
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:
getByRole("button", { name: ... }) — closest to how a screen reader sees the page.getByLabelText— form fields.getByText— for static text.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.
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
| Gate | Suite | Budget |
|---|---|---|
| Pre-commit / pre-push | Unit + changed integration | 30s |
| PR (CI) | Full unit + integration | 5 min |
| Main → staging | Add E2E | 15 min |
| Nightly | Cross-browser, visual regression | hours |
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.