frontend / testing / 01_testing_strategy.md

Testing Strategy and the Pyramid for Frontend

6 min read source

Testing Strategy and the Pyramid for Frontend

TL;DR

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.

Interview Q&A

Q: What are you testing for, exactly?

A: 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.

Q: The testing pyramid for frontend — what tiers?

A:


     /  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.

Q: What goes in each tier?

A:

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.

Q: How many of each?

A: 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.

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

A:

Implementation test (fragile):

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):

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 useStateuseReducer → Redux → whatever; the test still passes.

Q: What about coverage targets?

A: 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 ../../backend/05_testing/strategy/04_test_data_and_ci_parallelization.md.

Q: How do you handle flaky tests?

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

  • TimeDate.now(), setTimeout without mocking. Fix: vi.useFakeTimers().
  • Order dependence — test A leaves state for test B. Fix: reset between tests.
  • Race conditionsexpect 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.

Q: When does a test belong in a Storybook story instead?

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

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.

Q: Component vs visual vs E2E — when each catches what?

A:

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.

Q: How long should the suite take?

A: 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 / edge cases

  • 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.

What a senior is expected to say

  • “Test behavior, not implementation. RTL queries by role mirror what a user (and screen reader) sees — refactor-survivable.”
  • “The pyramid weights toward integration for frontend. Unit for pure logic, integration for components, E2E for critical journeys only.”
  • “Coverage is a signal, not a goal — 100% coverage with no assertions is worse than 70% with behavior tests. Diff coverage is more useful than total.”
  • “Flaky tests are bugs. The fix is finding the root cause (time, order, race, external) — never ‘just re-run.’”
  • “Speed matters — a 40-minute suite gets skipped. Sharding in CI, watch mode locally.”

Cross-references

Further reading