Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 10 of 12IntermediateAug 3, 2026

Testing Strategy

The test pyramid applied to frontend, why the shape matters more than the count, and which test types belong at which CI gate.

frontend-system-designtestingci

Why It Matters

Testing strategy is an architecture decision because it determines two things that compound: how fast your team gets feedback, and whether they believe the feedback when they get it.

A suite that takes forty minutes and fails randomly does not prevent bugs. It trains developers to re-run it, to merge on red, and eventually to ignore it entirely. The count of tests is close to irrelevant; the shape and speed of the suite is what determines whether it works.

The Pyramid, Applied to Frontend

Unit tests

Pure functions and isolated logic - formatters, validators, reducers, calculation helpers, custom hooks tested in isolation.

Milliseconds each, deterministic, and a failure points at exactly one function. Cheap enough to run on every keystroke.

Their limit is that they test units in isolation, and most frontend bugs are not in units. A perfectly correct formatter called with the wrong argument produces a bug that no unit test will ever catch.

Integration tests

A component rendered with its real children and real state, exercised the way a user would use it. Render a checkout form, type into it, submit it, assert on what appears.

This is the highest-value layer in frontend and the one teams most reliably under-invest in. Most frontend bugs are integration bugs: a prop passed in the wrong shape, state that does not update, a handler wired to the wrong element, a conditional branch that never renders. Unit tests miss these because they mock the collaborators away - which is exactly where the bug is.

The rule that keeps this layer valuable is do not mock your own components. Mock the network boundary; render everything else for real. A test that mocks the child component is testing the mock.

End-to-end tests

A real browser, the real application, ideally a real backend. Full user journeys - sign up, add to cart, check out, receive confirmation.

They are the only tests that verify the whole system actually works together, including routing, authentication, and deployment configuration. They are also slow (seconds to minutes each), flaky by nature (timing, network, browser state), and expensive to diagnose - "checkout failed" does not tell you which function returned the wrong value.

Keep them few and reserve them for revenue-critical or trust-critical paths.

Visual regression tests

Render a component, screenshot it, diff against an approved baseline. This catches the entire class of bugs that assertion-based tests structurally cannot see: a CSS change that breaks a layout three components away, a token change that makes text unreadable, an unintended shift in spacing.

No functional test will ever catch "the button is now invisible against its background."

Diagram
100%
flowchart TB subgraph P["The shape that works"] E["E2E - a handful<br/>signup, checkout, payment<br/>minutes each, flaky, whole-system confidence"] V["Visual regression - scoped<br/>design system + key layouts<br/>seconds each, catches what assertions cannot"] I["Integration - the bulk<br/>components with real children and state<br/>~100ms each, where most real bugs live"] U["Unit - many<br/>formatters, validators, reducers, hooks<br/>~1ms each, pinpoint failures"] end E --- V --- I --- U Note1["Slow, expensive, ambiguous failures"] -.-> E Note2["Fast, cheap, precise failures"] -.-> U style E fill:#3f1e1e,stroke:#ef4444 style V fill:#3f2d1e,stroke:#f59e0b style I fill:#1e3a5f,stroke:#3b82f6 style U fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

Why the Shape Matters

Too many e2e tests

The failure mode is not "the tests are bad." It is that the suite stops functioning as a signal:

Feedback collapses. A forty-minute suite means the loop between writing code and learning it broke is forty minutes. Developers stop running it locally and start pushing speculatively.

Flakiness destroys trust. E2e tests fail for reasons unrelated to your change - a slow network, a race, a stale test database. Once a team learns that red might mean nothing, the correct response to red becomes "re-run it," and the suite has stopped preventing anything.

Debugging is slow. A failure says "the checkout flow broke." Finding out why means reproducing a browser session.

Cost scales badly. Every e2e test needs a full environment. Parallelising to keep the wall clock down means paying for that environment many times over.

Too few integration tests

The mirror image, and the more common failure. A team with good unit coverage and a few e2e happy paths has a gap exactly where most bugs live - the seams between components. Unit tests mocked those seams away, and e2e tests only walk the happy path.

The symptom is recognisable: high coverage numbers, and bugs still reaching production regularly.

Where Each Type Belongs in CI

The organising principle: each gate should catch what the previous one could not, without repeating what it already did.

Diagram
100%
flowchart TB C["Commit / push"] --> G1{"GATE 1 - every commit<br/>target: under 2 min"} G1 --> L["Lint + typecheck"] G1 --> UT["Unit tests"] G1 --> CT["Component / integration tests"] G1 --> A11["Automated a11y rule checks"] L --> P1{"Pass?"} UT --> P1 CT --> P1 A11 --> P1 P1 -->|"no"| Block1["BLOCK - fast, precise feedback"] P1 -->|"yes"| G2{"GATE 2 - pre-merge<br/>target: 10-15 min"} G2 --> Full["Full integration suite"] G2 --> VR["Visual regression"] G2 --> E2E["E2E - critical paths only"] G2 --> Budget["Bundle size budget"] Full --> P2{"Pass?"} VR --> P2 E2E --> P2 Budget --> P2 P2 -->|"no"| Block2["BLOCK merge"] P2 -->|"yes"| M["Merge + deploy"] M --> G3{"GATE 3 - post-deploy<br/>target: under 3 min"} G3 --> Smoke["Smoke suite against the<br/>real deployed environment"] Smoke --> P3{"Pass?"} P3 -->|"no"| RB["Auto-rollback + page on-call"] P3 -->|"yes"| Live["Release confirmed"] style G1 fill:#1e3f2d,stroke:#22c55e style G2 fill:#1e3a5f,stroke:#3b82f6 style G3 fill:#3f2d1e,stroke:#f59e0b style RB fill:#3f1e1e,stroke:#ef4444
visualized byIOCombats

Gate 1 - every commit. Lint, types, unit, component tests, automated a11y rule checks. Target under two minutes. This gate exists to keep developers in flow; every second added here is paid by every developer on every push.

Gate 2 - pre-merge. The full integration suite, visual regression, e2e over critical paths, and the bundle budget, running against a preview deployment. Ten to fifteen minutes is acceptable because it runs once per pull request rather than once per commit. This is the gate that protects the main branch.

Gate 3 - post-deploy. A small smoke suite against the real deployed environment. Its job is not to find logic bugs - Gate 2 did that - but to catch what only appears in production: wrong environment variables, a misconfigured CDN, a missing secret, a broken integration. Wire it to automatic rollback.

Two practices make this pipeline survivable at scale:

Parallelise and shard. Test time should scale with runners, not with test count.

Only run what changed. In a monorepo, the build graph knows which packages a change affects. Running the other forty packages' tests is pure waste, and it is the difference between a pipeline that stays fast and one that gets slower every month.

Making Tests Worth Keeping

Test behaviour, not implementation. Query by role and visible text; assert on what a user would perceive. A test that reaches into internal state, asserts on a CSS class name, or checks that a child received a specific prop breaks on every refactor that changes nothing observable. Those tests are a tax on refactoring and they train the team to distrust failures.

Mock the network, not your own code. The network boundary is a real seam with a stable contract. Your component tree is not.

Attack flakiness at the source. Control time, control randomness, wait on state rather than on timeouts, and reset data between runs. A quarantined flaky test is a deferred decision, not a fix - it either gets repaired or deleted.

For visual regression specifically, nondeterminism is the whole battle: render in a fixed containerised browser so font rendering does not drift, freeze anything time- or random-dependent, set a small pixel threshold so subpixel noise does not fail a build, and target isolated components rather than full pages, since a full-page diff fails whenever anything on it moves.

Tradeoffs

OptionProsConsWhen to Use
Unit testsMilliseconds, deterministic, failures point at one functionMiss integration bugs entirely, easy to over-mock into meaninglessnessPure logic - formatters, validators, reducers, hooks
Integration testsCatch the bugs that actually occur, survive refactoring, still fastSlower than unit, need a rendering environmentThe bulk of the suite - this is the layer to invest in
E2E testsOnly tests that verify the whole system including deploy configSlow, flaky, expensive, ambiguous failuresA handful of revenue- and trust-critical journeys
Visual regressionCatches layout and styling breakage nothing else can seeFalse positives without strict determinism control, baselines need maintenanceDesign system components and a few key layouts
Everything on every commitMaximum safety, nothing slips throughSlow gate, developers stop running tests locallySmall projects with a fast suite
Tiered gatesFast feedback where it matters, thorough checks before mergeMore pipeline configuration, needs preview environmentsAny team past a handful of engineers
Affected-only test runsPipeline time stays flat as the repo growsRequires a correct dependency graph; a wrong graph skips real coverageMonorepos with Nx, Turborepo, or equivalent

Where This Applies

The a11y checks placed at Gate 1 and Gate 2 are the automated layer described in Accessibility Architecture, which also explains what those checks cannot catch and why manual testing stays in the process. Affected-only test selection and CI caching are covered in Build Tooling and Developer Experience, and the bundle budget enforced at Gate 2 is defined in Performance Engineering.

In the applied practice problems, this determines how each design is validated - Collaborative Document warrants property-based convergence testing, Multi-Step Form warrants one end-to-end pass per branch, and Autocomplete / Typeahead warrants an integration test that resolves responses out of order on purpose.

Advertisement

Frequently Asked Questions

Why is an inverted pyramid - mostly end-to-end tests - a problem, even if coverage is high?

Because of what it does to feedback and trust. An e2e suite is slow, so the loop between writing code and learning it broke stretches from seconds to tens of minutes, and developers stop running tests locally. It is also flaky by nature, since it depends on timing, network, and a real browser, so failures become ambiguous - and once a team learns that red might mean nothing, they start re-running instead of investigating, at which point the suite has stopped being a signal. And when something does genuinely break, an e2e failure tells you the checkout flow failed, not which function returned the wrong value, so debugging is slow too.

Where do most frontend teams under-invest, and why does it matter?

The integration layer - components rendered with their real children and real state, exercised the way a user would. Teams tend to have unit tests for utilities and e2e tests for happy paths, with a gap in between. That gap is where most real bugs live, because most frontend bugs are integration bugs - a prop passed in the wrong shape, state that does not update, a handler wired to the wrong element, a conditional branch that never renders. Unit tests miss these because they mock the collaborators, and e2e tests catch them slowly and expensively if at all.

What makes a component test valuable rather than a maintenance burden?

Testing behaviour instead of implementation. A test that queries by role and visible text and asserts on what the user would see survives refactoring, because it depends only on the contract the component offers its user. A test that reaches into internal state, asserts on a CSS class name, or checks that a specific child component received a specific prop breaks every time someone restructures the component without changing what it does. That kind of test creates a tax on refactoring and provides negative value, because the team learns to distrust failures.

Visual regression tests are notorious for false positives. How do you make them useful?

Attack the sources of nondeterminism directly. Render in a fixed, containerised browser rather than on whatever the developer has locally, so font rendering and antialiasing do not drift. Freeze anything time or randomness dependent, and stub any content that changes between runs. Set a small pixel-difference threshold so subpixel noise does not fail a build. And target components in isolation rather than full pages, since a full-page diff fails when any single element moves. Scope also matters - run them over the design system and a handful of key layouts, not over every screen.

How do you decide which tests run at which CI gate?

Trade speed against confidence and place each gate accordingly. Every commit gets the fastest possible signal - lint, types, unit and component tests - targeting a couple of minutes so developers stay in flow. Pre-merge is where you can afford more, so the full integration suite, visual regression, and e2e over critical paths run against a preview deployment, targeting ten to fifteen minutes. Pre-deploy and post-deploy is a small smoke suite against production or staging, checking that the deployed artifact actually works in its real environment. The principle is that each gate should catch what the previous one could not, without repeating what it already did.