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
The recommended distribution
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.