The frontend testing ratio

What it is

The testing ratio is how you distribute a fixed testing budget across layers that differ by three orders of magnitude in cost and by a large margin in what they can prove.

Layer                 Runs in      Typical    Catches                    Flake
                                   duration
------------------------------------------------------------------------------
Static (TS, ESLint)   compiler     ms         type errors, bad imports,  none
                                              hook rule violations
Unit (pure logic)     node         <5ms       reducer/selector/format    none
                                              logic
Component/integration jsdom        20-200ms   a feature working through  low
                                              its real DOM
Component (real       browser      0.5-3s     anything geometry or       low
  browser)                                    CSS dependent
E2E                   browser +    5-60s      the whole system, real     HIGH
                      real backend            network, real routing
Visual regression     browser      2-10s      CSS regressions no DOM     medium
                                              assertion can see
Contract              node         <100ms     the API changing under     none
                                              you

The frontend-specific claim, and the one that differs from backend advice: the bulk of the value sits in the component/integration layer, not the unit layer. A React component's units are mostly not where the bugs are; the bugs are in how a component, its state, its data layer and the DOM behave together.

What this is confused with: the test pyramid applied unchanged to a UI. Cohn's pyramid (many unit, fewer service, few UI) was written about server systems where a "unit" is a meaningful behaviour boundary. In a component UI, a test that mounts a component and interacts with it is cheap enough that the pyramid's cost argument does not hold, which is why Kent C. Dodds's "testing trophy" reshapes it with integration as the widest layer.

Also confused: coverage percentage as the target. Coverage measures which lines executed, not whether anything was asserted about them. A snapshot test that nobody reads gives 100 percent coverage of a broken component.

The problem it solves

The two failure modes are symmetrical and both are common.

Too many unit tests, coupled to implementation.

// Tests the implementation, not the behaviour.
it('calls setState with the new filter', () => {
  const setState = jest.fn()
  jest.spyOn(React, 'useState').mockReturnValue(['', setState])
  render(<FilterBar />)
  fireEvent.change(screen.getByRole('textbox'), {target:{value:'x'}})
  expect(setState).toHaveBeenCalledWith('x')
})

Consequence: refactoring useState to useReducer breaks 40 tests that
were all still describing correct behaviour. The suite now DISCOURAGES
refactoring, which is the opposite of its purpose.

Too many E2E tests, and the arithmetic that kills them. Flake compounds multiplicatively:

Per-test pass probability p, n tests, all independent:
  P(green run) = p^n

p = 0.99, n = 50    -> 0.99^50  = 60.5%
p = 0.99, n = 200   -> 0.99^200 = 13.4%
p = 0.999, n = 200  -> 0.999^200 = 81.9%

With 200 E2E tests that are each 99% reliable, a fully green run
happens about one time in seven. The team stops believing red builds
and starts re-running until green, at which point the suite provides
NO SIGNAL while still costing the full runtime.

That arithmetic is the whole argument for keeping E2E small. Reaching 99.9 percent per-test reliability is expensive; keeping n at 20 instead of 200 is a decision.

Mechanics

For a typical product frontend:

Static analysis          everything. TypeScript strict, ESLint with
                         react-hooks rules, a11y lint. Not negotiable
                         and effectively free.

Unit                     ~15% of tests. ONLY genuinely pure logic:
                         reducers, selectors, formatters, date and
                         currency handling, parsing, sorting
                         comparators, permission predicates.

Component/integration    ~70%. The bulk. Render a feature, interact
                         with it as a user, assert on what a user
                         would observe. Mock at the NETWORK boundary
                         (MSW), not at the module boundary.

E2E                      ~5%, and a hard cap on count. 10 to 30 tests
                         covering the journeys where failure is
                         unacceptable: sign-in, checkout, the primary
                         create/edit flow, a permissions boundary.

Visual regression        ~10%, and only on the design system's
                         components plus a handful of key page
                         layouts. Not on every page.

Contract                 as many as you have API endpoints you depend
                         on, generated rather than written.

Component tests done right

Query the way a user finds things, which makes the test resilient to refactoring and doubles as an accessibility check.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

// Mock at the NETWORK boundary. The component's data layer, cache,
// error handling and retry logic all run for real.
const server = setupServer(
  http.get('/api/shipments', () =>
    HttpResponse.json([{ id: 1, ref: 'SH-1', status: 'delayed' }])),
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test('filtering to delayed shows only delayed shipments and updates the count', async () => {
  const user = userEvent.setup()
  render(<ShipmentsPage />)

  // Query by ROLE and accessible NAME: the same information a screen
  // reader uses. A test that cannot find the button by its name is
  // telling you the button has no accessible name.
  await user.click(await screen.findByRole('button', { name: /filters/i }))
  await user.click(screen.getByRole('checkbox', { name: /delayed/i }))

  expect(await screen.findByText('1 shipment')).toBeInTheDocument()
  expect(screen.getByRole('row', { name: /SH-1/ })).toBeInTheDocument()
})

The priority order for queries (from Testing Library's own guidance): getByRole with a name, then getByLabelText for form fields, then getByText, and getByTestId only as a last resort. A data-testid is an admission that the element is not identifiable the way a user identifies it, which is usually a real finding rather than a testing inconvenience.

What not to do:

// Implementation coupling: breaks on refactor, proves nothing.
expect(wrapper.find('FilterPanel').prop('isOpen')).toBe(true)
expect(wrapper.state('filters')).toEqual(['delayed'])

// Snapshot tests of large trees: they fail on every change, get
// updated with -u without reading, and then assert nothing.
expect(container).toMatchSnapshot()

Snapshots are useful for small, stable, intentionally-frozen output (a formatted error message, a generated query string) and harmful for component trees, because the review step, which is the entire value, does not survive contact with a 400-line diff.

The jsdom boundary

jsdom has no layout engine. These all return zero or lie:

getBoundingClientRect()       zeros
offsetWidth / offsetHeight    zeros
IntersectionObserver          not implemented (needs a polyfill/mock)
ResizeObserver                not implemented
CSS cascade / media queries   parsed but not applied to layout
scrollIntoView                a no-op
element visibility            based on inline styles and attributes
                              only, not on computed layout

So anything geometry-dependent must be tested in a real browser: virtualised lists, drag and drop, popover positioning, sticky headers, responsive behaviour, focus-visible styling, and overflow. Playwright's component testing or Cypress component testing runs the same component in a real browser at roughly 10 to 20 times the cost of a jsdom test, which is the right trade for a small number of components.

Making E2E tests not flake

1. AUTO-WAITING LOCATORS, never fixed sleeps.
     await expect(page.getByRole('alert')).toHaveText('Order placed')
   Playwright retries the assertion until timeout. `await
   page.waitForTimeout(2000)` is both slower and flakier.

2. TEST ISOLATION. Each test creates its own data via an API call in
   setup and never depends on another test's state or ordering.
   Shared fixtures are the single largest flake source.

3. DETERMINISTIC TIME AND RANDOMNESS. Freeze the clock, seed any
   randomness, and pin anything date-dependent. "Fails only on the
   1st of the month" is a real bug class.

4. DISABLE ANIMATIONS in the test environment.
     prefers-reduced-motion, or a global CSS override.

5. NETWORK: decide per test. Real backend for the handful of true
   E2E journeys; intercepted for everything else, because a flaky
   third-party dependency in CI is not a signal about your code.

6. A FLAKE BUDGET WITH TEETH. Retries hide flake; a quarantine
   makes it visible. Track per-test flake rate, auto-quarantine
   anything over a threshold, and treat the quarantine list as a
   bug backlog with an owner. Retries without measurement is how
   a suite silently stops meaning anything.

Visual regression, scoped

It catches what no DOM assertion can: a CSS change that makes text white on white, a layout that collapses at one breakpoint, a design-token change that ripples somewhere nobody looked.

Its cost is false positives: font rendering differences between machines, anti-aliasing, dynamic content, animation mid-frame. The controls:

- run in a single pinned browser/container image, never on a developer
  machine as the baseline
- mask or stub dynamic regions (timestamps, avatars, live counts)
- disable animations and wait for fonts (document.fonts.ready)
- set an explicit pixel threshold, and treat a rising threshold as
  a signal that the setup is wrong
- scope it to the DESIGN SYSTEM and a few key layouts. Screenshotting
  every page produces a review burden nobody sustains.

Contract testing, the frontend's real integration risk

Most "frontend bugs" in a service architecture are the API changing. Two approaches:

GENERATED TYPES (preferred when you control or can consume a schema):
  OpenAPI or GraphQL schema -> generated TypeScript types, checked
  in CI against the deployed schema. A removed field or a changed
  nullability becomes a COMPILE error, at zero runtime cost.

CONSUMER-DRIVEN CONTRACTS (Pact) when there is no shared schema:
  the frontend publishes the shape it depends on; the provider's CI
  verifies it still satisfies it.

Generated types plus a schema-diff check in CI covers most of the value for a fraction of the effort, and it is the one layer teams most often skip while writing E2E tests that would have caught the same class of bug more slowly.

A worked example: a suite that took 47 minutes and proved nothing

A dashboard application. The test suite at the start:

Layer                    Count   Runtime      Notes
---------------------------------------------------------------
Unit (enzyme, shallow)   1,840   4 min        mostly prop/state
                                              assertions
Snapshot                   310   1 min        auto-updated with -u
E2E (Cypress)              214   42 min       against a shared
                                              staging environment
---------------------------------------------------------------
Total                    2,364   47 min

Green-run rate over 30 days: 31%.
Median re-runs to green: 2.
Escaped bugs in the same period that reached production: 19.

The suite cost 47 minutes, was green less than a third of the time, and did not prevent 19 production bugs. The team's stated belief was that they needed more tests.

The diagnosis, taking the 19 escaped bugs and asking which layer would have caught each:

Cause of escaped bug                          Count  Layer that catches it
------------------------------------------------------------------------
API response shape changed (field removed
  or nullability changed)                        7   contract / generated
                                                     types
Component broke when a real data layer was
  involved (cache invalidation, loading
  states, error paths)                           6   component/integration
CSS regression (element invisible or
  overlapping at one breakpoint)                 3   visual regression
Genuine cross-system journey failure             2   E2E
Pure logic error in a date calculation           1   unit

Seven of nineteen were the API changing, and the suite had no layer that could see it. The 1,840 unit tests all mocked the API module and asserted on props, so a changed response shape passed every one of them.

And the flake arithmetic explained the green-run rate exactly:

Measured per-test reliability across the 214 E2E tests: ~0.995
P(green) = 0.995^214 = 0.343  -> observed 31%, close enough given
                                that flake is not fully independent

The rebuild:

                       before            after
Static                 TS non-strict     TS strict + eslint
                                         react-hooks + jsx-a11y
Unit                   1,840             240 (pure logic only)
Snapshot                 310             12 (small frozen outputs)
Component/integration      0             680 (RTL + MSW)
E2E                      214             22 (the money paths)
Visual regression          0             90 (design system + 6
                                         layouts)
Contract                   0             generated types from the
                                         OpenAPI schema, diffed in CI
---------------------------------------------------------------
Runtime                47 min            9 min
Green-run rate         31%               94%

Where the 1,600 deleted unit tests went: roughly 680 were rewritten as component tests covering the same behaviour and more, and the rest tested implementation details of components that had since been refactored. They were not deleted because they failed, they were deleted because passing them proved nothing about the product.

The E2E cut from 214 to 22 was the contentious decision, and the framing that carried it:

"We are not reducing coverage, we are moving it down a layer where
 it is 30 times faster and does not flake. The 22 that remain are
 the journeys where we would roll back a release: sign-in, SSO,
 checkout, the permission boundary, and data export."

Flake arithmetic after: 0.995^22 = 89.5%, and with the isolation and
determinism fixes per-test reliability rose to ~0.9995, giving
0.9995^22 = 98.9%.

The generated-types layer, which cost the least, prevented the largest single bug category. A CI job pulled the deployed OpenAPI schema, regenerated types, and failed the build on a diff:

Caught in the first month:
  - 3 fields changed from required to nullable in an upstream service
  - 1 enum gained a variant the UI's exhaustive switch did not handle
  - 1 endpoint's pagination shape changed

All five would previously have been production incidents found by
users, because no test in the old suite mocked anything but the
happy-path shape the frontend already believed in.

That last clause is the general lesson: a hand-written mock encodes what you believe the API returns, so it can never tell you that belief is wrong. Only something derived from the provider can.

Production evidence

Testing Library's query priority (role and accessible name first, data-testid last) is documented guidance from the library's maintainers, and the reason given is that tests should resemble how users find elements, which is why role-based queries double as an accessibility check.

Mock Service Worker (MSW) intercepts at the network layer using Service Worker in the browser and request interception in Node, which is what makes it possible to run a component's real data layer, cache and error paths in a test rather than stubbing the module that fetches.

Playwright's auto-waiting locators and web-first assertions retry until a timeout rather than asserting once, which is the mechanism behind its documented reduction in timing flake compared to fixed waits.

Google's published testing guidance describes a flaky test as worse than no test, because it trains engineers to ignore failures, and Google has written about running dedicated infrastructure to detect and quarantine flaky tests at scale. That is the operational form of the flake-budget control.

Kent C. Dodds's "testing trophy" is the widely adopted articulation of integration-heavy frontend testing, and its argument is explicitly about confidence per unit of cost rather than about test counts.

Chromatic and Percy are the two most widely used hosted visual regression services, and both document the same controls: pinned rendering environment, masking of dynamic regions, and font loading waits, which is corroboration that false positives are the dominant operational cost.

The debate

Is the pyramid wrong for frontends? For component UIs, its cost premise does not hold: mounting a component and interacting with it costs tens of milliseconds, not seconds. The position: integration is the widest layer, unit tests are for pure logic only, and E2E is a small deliberate set. The pyramid still describes E2E correctly, which is the part people get wrong in the other direction.

Should E2E tests hit a real backend? For the small set of true journeys, yes, because otherwise they are integration tests wearing a costume. For everything else, intercept, because a flaky third-party dependency in CI is not information about your code. The failure is running 200 tests against a shared staging environment, where you have coupled your build's reliability to someone else's deploy.

Are snapshot tests useful? For small, stable, deliberately frozen output, yes. For component trees, no, because the review step is the entire value and nobody reviews a 400-line diff; the observed behaviour is -u and move on. If a snapshot is never read when it fails, it is coverage theatre.

Is coverage a useful target? As a floor and a trend, weakly; as a goal, no. Coverage measures execution, not assertion, and teams pushed to a number write tests that execute code and assert nothing. The better target is: which of the last N production bugs would this suite have caught, which is the analysis that restructured the suite in the worked example.

Should you retry flaky tests? Retry to keep the pipeline moving, and only alongside measurement. Retries without a per-test flake rate and a quarantine process is how a suite silently stops meaning anything, because every flake is absorbed rather than counted. Track the rate, quarantine above a threshold, and treat the quarantine list as an owned backlog.

Is visual regression worth the false positives? On the design system, clearly: one component's regression affects everywhere it is used. Across every page, usually not, because the review burden grows with page count while the marginal catch rate falls.

Follow-up Q&A

"How do you decide the testing ratio for a frontend?"

Static analysis on everything, since it is free. Unit tests only for genuinely pure logic: reducers, selectors, formatters, parsers, permission predicates. The bulk in component/integration tests that render a feature, interact with it as a user would, and mock at the network boundary so the real data layer, cache and error handling run. A small capped set of E2E tests, 10 to 30, covering journeys where failure means rollback. Visual regression scoped to the design system plus a few layouts. And generated types or contract tests against the API, which is the layer most teams skip and the one that catches the most.

"Why cap the number of E2E tests rather than the runtime?"

Because flake compounds multiplicatively. With per-test reliability of 0.99, fifty tests give a 60 percent chance of a green run and two hundred give 13 percent. Below about a 90 percent green rate the team stops reading failures and starts re-running until green, at which point the suite costs its full runtime and provides no signal. Getting per-test reliability to 99.9 percent is expensive; keeping the count at 20 is a decision you can make today.

"What is wrong with mocking the API module in tests?"

A hand-written mock encodes what you believe the API returns, so it can never tell you that belief is wrong. In one audit, seven of nineteen escaped production bugs were API shape or nullability changes, and all 1,840 unit tests passed because every one of them mocked the fetch module with the shape the frontend already assumed. Mock at the network boundary instead, with MSW, so the data layer runs for real, and add generated types diffed against the deployed schema in CI so a removed field is a compile error.

"What can jsdom not test?"

Anything involving layout, because jsdom has no layout engine. getBoundingClientRect and offsetWidth return zeros, IntersectionObserver and ResizeObserver are absent, CSS is parsed but not applied to layout, and visibility is computed from inline styles rather than from the rendered result. So virtualised lists, drag and drop, popover positioning, sticky behaviour, overflow and responsive breakpoints need a real browser, which is what Playwright or Cypress component testing is for, at roughly ten to twenty times the cost per test.

"How do you keep E2E tests from flaking?"

Auto-waiting locators and retrying assertions instead of fixed sleeps; per-test data creation so no test depends on another's state or ordering; frozen clock and seeded randomness; animations disabled; and a decision per test about whether to hit a real backend or intercept. Then a flake budget with teeth: measure per-test flake rate, auto-quarantine above a threshold, and treat quarantine as an owned backlog. Retrying without measuring is how a suite stops meaning anything while still appearing to pass.

"Is high coverage a good goal?"

No, because coverage measures which lines executed, not whether anything was asserted about them, and a team pushed to a number writes tests that execute code and assert nothing. A snapshot test of a broken component gives full coverage of it. The better question, and the one that actually restructures a suite, is which of the last twenty production bugs this suite would have caught, and which layer would have caught each. That analysis usually shows the missing layer is not more of what you already have.

Common misconceptions

"The test pyramid applies to frontends unchanged." Its cost premise does not hold when mounting a component costs tens of milliseconds. Integration is the widest layer in a component UI.

"More E2E tests means more confidence." Past a point it means less, because flake compounds and a suite that is green 31 percent of the time trains people to ignore it.

"Coverage percentage measures test quality." It measures execution. Assertion is a separate property and coverage cannot see it.

"Snapshot tests catch regressions." Only if someone reads the diff. On large component trees the observed behaviour is updating with -u.

"Mocking the API module is equivalent to mocking the network." A module mock skips your data layer, cache, error handling and retry logic, and it encodes your existing beliefs about the response shape, so it cannot catch the API changing.

"jsdom is a browser." It has no layout engine, so anything geometric silently passes or silently lies.

Interview delivery note

Say this verbatim: "Flake compounds multiplicatively. Two hundred E2E tests at 99 percent per-test reliability gives you a 13 percent chance of a green run, so the team stops believing failures and re-runs until green. That is why the E2E count is capped and the coverage moves down to component tests that mock at the network boundary." One piece of arithmetic that makes an intuitive argument unarguable.

The senior-versus-staff separator is diagnosing a suite by the bugs that escaped it. A senior engineer proposes a better ratio. A staff engineer takes the last twenty production bugs, assigns each to the layer that would have caught it, and discovers that seven were API shape changes that no existing layer could see because every test mocked the fetch module with the shape the frontend already believed. Then the fix is a new layer, generated types diffed against the deployed schema, rather than more of what already exists.

The second signal is naming the mock boundary as the decision. Saying "mock at the network boundary rather than the module boundary, because a hand-written module mock encodes what you believe the API returns and therefore can never tell you that belief is wrong" is a compressed statement of why most frontend test suites miss their most common bug class.

Further reading

  • Testing Library documentation on query priority, and the reasoning that role-based queries mirror how users and assistive technology find elements.
  • Mock Service Worker documentation, for network-boundary interception in both browser and Node.
  • Playwright documentation on auto-waiting locators and web-first assertions, and its guidance on test isolation.
  • Google's testing blog on flaky tests, including why a flaky test is worse than no test and how quarantine is operated at scale.
  • Kent C. Dodds, "The Testing Trophy and Testing Classifications," for the confidence-per-cost argument behind an integration-heavy frontend ratio.