Frontend / Testing / 01_testing_strategy.md

Testing Strategy and the Pyramid for Frontend

Updated 5 interview angles 6 min read source
On this page14
  1. What are you testing for, exactly?
  2. The testing pyramid for frontend — what tiers?
  3. What goes in each tier?
  4. How many of each?
  5. What does “test behavior, not implementation” actually mean?
  6. What about coverage targets?
  7. How do you handle flaky tests?
  8. When does a test belong in a Storybook story instead?
  9. Component vs visual vs E2E — when each catches what?
  10. How long should the suite take?
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Testing Strategy and the Pyramid for Frontend

Test behavior, not implementation. The traditional pyramid (many unit, fewer integration, very few E2E) still applies, but frontends benefit from a broader middle layer — RTL/Testing-Library integration tests catch most real bugs at a fraction of E2E’s cost. Each tier has a specific purpose; over-investing in one (a thousand unit tests, no E2E) gives false confidence.

What are you testing for, exactly?

Three goals, in order:

  1. Catch regressions — tests fail before bugs ship.
  2. Document behavior — the test names + assertions communicate what the code does.
  3. Enable refactor — tests survive implementation changes if they’re written against behavior, not internals.

The third is where junior tests fail. A test that asserts “the count state variable equals 3” breaks when you rename or restructure. A test that asserts “the screen shows Count: 3” survives any refactor that doesn’t change behavior.

The testing pyramid for frontend — what tiers?

text
        ▲
     /  E2E   \      few — critical user journeys (Playwright)
    /---------\
   / Integration\    many — components together, with MSW (Testing Library)
  /-------------\
 /   Unit         \  some — pure logic, utilities, reducers (Vitest)
/-----------------\

Differences from backend pyramid:

  • Frontend integration is cheaper than backend integration — Testing Library runs in milliseconds, no containers or fixtures.
  • Frontend “unit” is narrower — most of your code is component-shaped, which is better tested as integration.
  • The “testing trophy” (Kent C. Dodds) — wide middle (integration), narrow ends — fits frontends better than the pyramid in many cases.

What goes in each tier?

Unit (Vitest):

  • Pure functions, utilities (date formatters, validators, calculators).
  • Reducers, state machines.
  • Custom hooks where the hook is purely computational.

Integration (Testing Library + MSW):

  • Components rendering, responding to user events, calling APIs.
  • Forms — fill, submit, see result.
  • Routing — click link, navigate, see new page.
  • Custom hooks that integrate with React (data fetching hooks, subscription hooks).

E2E (Playwright):

  • Critical user journeys (sign up, checkout, primary task).
  • Anything cross-tab / multi-page that integration can’t cover.
  • Browser-specific behavior verification.

How many of each?

No magic number, but a rough guide for a mid-size app:

Tier Count for “feature complete”
Unit 50-200 per app (mostly utilities)
Integration 100-500 — one per meaningful screen + key interactions
E2E 5-30 — the 5-30 things that must work

If you have 5000 unit tests and 3 integration tests, your suite is the wrong shape — most bugs ship in component integration, not utility logic.

What does “test behavior, not implementation” actually mean?

Implementation test (fragile):

tsx
it("calls setCount", () => {
  const setCount = vi.fn();
  vi.spyOn(React, "useState").mockReturnValue([0, setCount]);
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setCount).toHaveBeenCalledWith(1);
});

This breaks the moment you rename setCount or switch to useReducer.

Behavior test (durable):

tsx
it("increments the count", async () => {
  render(<Counter />);
  const user = userEvent.setup();
  expect(screen.getByText("Count: 0")).toBeInTheDocument();
  await user.click(screen.getByRole("button", { name: /increment/i }));
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

The test exercises the same path a real user takes. Refactor useState → useReducer → Redux → whatever; the test still passes.

What about coverage targets?

Coverage is a signal, not a goal. 100% coverage with assertion-free tests is worse than 70% coverage with behavior tests. Reasonable targets:

  • Critical paths (auth, payment, data integrity): aim for high (~90%).
  • Utilities: high (easy to test, high ROI).
  • UI styling / animation: low (test what behavior they enable, not the styles).
  • Generated code / vendor: skip.

Diff coverage (was the changed code in this PR tested?) is more useful than total coverage. See Test Data, Flaky Tests, and CI Parallelization.

How do you handle flaky tests?

A flaky test is a bug, not a nuisance. Causes:

  • Time — Date.now(), setTimeout without mocking. Fix: vi.useFakeTimers().
  • Order dependence — test A leaves state for test B. Fix: reset between tests.
  • Race conditions — expect before async work resolved. Fix: await findBy* or waitFor.
  • External dependencies — real network calls. Fix: MSW.
  • Browser differences (E2E) — different headless modes timing differently.

The cardinal sin: “just re-run it.” Skip flaky tests with .skip and a JIRA ticket; don’t ship a suite that lies about passing.

When does a test belong in a Storybook story instead?

Storybook has “play functions” — interactions you can attach to a story:

tsx
export const FilledForm: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.type(canvas.getByLabelText(/email/i), "test@test.com");
    await userEvent.click(canvas.getByRole("button", { name: /submit/i }));
  },
};

Storybook + interactions is great for visual regression of states (filled form, error state, loading state) and for review by designers/product. Pure logic tests stay in Vitest; component states benefit from being visible in Storybook.

Component vs visual vs E2E — when each catches what?

Bug Caught by
Button click does wrong thing Component test (RTL)
Form validation accepts bad input Component test
Data fetching error not shown Component test + MSW
Color regression after theme refactor Visual regression (Chromatic)
Layout shift on mobile breakpoint Visual regression at multiple viewports
Login flow broken E2E
Real browser bug (Safari-specific) E2E with browser matrix
Accessibility regression A11y unit test + manual audit
Cross-tab consistency E2E (or manual)

Component tests catch most bugs. Visual regression catches the ones component tests can’t see. E2E catches the ones that need a real browser context.

How long should the suite take?

Targets that protect productivity:

  • Local unit + integration: < 30 seconds for the whole suite. Vitest watch mode keeps this < 5s.
  • Local on a focused test: < 1 second.
  • CI full suite: < 5 minutes (parallelize with sharding).
  • E2E suite: < 10 minutes (parallel browsers; consider running on PR vs nightly tiers).

A 40-minute suite gets skipped, gets -x’d, gets .only’d into and breaks. Speed is a feature.

Gotchas

  • render doesn’t unmount automatically in old setups — Testing Library auto-cleans between tests in modern setups; verify your config.
  • act warnings mean an update happened outside React’s batched mode — usually fixed by await-ing the right thing.
  • Mocking React itself (vi.mock("react")) is almost always wrong; you’ve stopped testing React.
  • Shared mutable state across tests — module-level variables, singletons, global Pinia/Redux stores — reset between tests.
  • Date-based assertions without fake timers will fail on certain days/timezones — always mock time.

Further reading

Interview angle 5

  • “What is the guiding principle?” - test behaviour, not implementation. A test that queries by role sees what a user and a screen reader see, so it survives a refactor that changes class names, internal state or component structure. Tests coupled to internals break on changes that broke nothing.
  • “What shape should the pyramid have on the frontend?” - weighted toward integration. Unit tests for pure logic, integration tests that render a component with its real children for most behaviour, and E2E reserved for a handful of critical journeys because they are slow and the flakiest.
  • “Is coverage a useful target?” - as a signal, not a goal. Full coverage with weak assertions is worse than 70% with real behaviour tests, because it reports safety that is not there. Diff coverage on the changed lines is the more actionable number.
  • “A test fails intermittently. What do you do?” - treat it as a bug and find the cause: a real time dependency, test-order coupling, an unawaited async update, or something external. Re-running until it passes trains the team to ignore the suite, and the flake is often a genuine race in the product.
  • “Why does suite speed matter?” - because a forty-minute suite gets skipped, and a skipped suite protects nothing. Shard in CI, run watch mode locally, and treat runtime as a feature of the test suite rather than an accident of it.