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.
Advertisement
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."
Diagram100%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:#22c55evisualized by
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.
Diagram100%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:#ef4444visualized by
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
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| Unit tests | Milliseconds, deterministic, failures point at one function | Miss integration bugs entirely, easy to over-mock into meaninglessness | Pure logic - formatters, validators, reducers, hooks |
| Integration tests | Catch the bugs that actually occur, survive refactoring, still fast | Slower than unit, need a rendering environment | The bulk of the suite - this is the layer to invest in |
| E2E tests | Only tests that verify the whole system including deploy config | Slow, flaky, expensive, ambiguous failures | A handful of revenue- and trust-critical journeys |
| Visual regression | Catches layout and styling breakage nothing else can see | False positives without strict determinism control, baselines need maintenance | Design system components and a few key layouts |
| Everything on every commit | Maximum safety, nothing slips through | Slow gate, developers stop running tests locally | Small projects with a fast suite |
| Tiered gates | Fast feedback where it matters, thorough checks before merge | More pipeline configuration, needs preview environments | Any team past a handful of engineers |
| Affected-only test runs | Pipeline time stays flat as the repo grows | Requires a correct dependency graph; a wrong graph skips real coverage | Monorepos 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
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.