A frontend testing strategy
How to split a frontend's tests between static checks, unit, integration, and end-to-end tests, so they catch the bugs that matter without making every change slow.
What tests are for
Picture eight engineers working on an online shop. On a Friday afternoon one of them renames a prop on the shared PriceTag component from amount to value. The change is small, the unit tests for the price formatter pass, and the pull request is merged. On Saturday morning the checkout page shows NaN where the total should be, and the shop loses a weekend of orders.
The team had 1,400 tests and 82 percent coverage. But none of them rendered the checkout page with the real PriceTag inside it, so none could see the bug. The tests checked the parts one at a time; the bug lived in the join between two parts.
A testing strategy is the plan for which tests you write, at which level, and how many, so that the bugs your users would hit are caught before they ship, at a price your team can afford.
Confidence per minute
A test is not free. You pay when you write it, every time CI runs it, every time it breaks because the code changed shape but not behavior, and every time it fails for no reason. In return, it gives you confidence: the belief that if the tests pass, the app works for real users.
So the useful question is not "how many tests do we have?" but "how much confidence does each test buy for each minute we spend on it over its life?" Call it confidence per minute. A cheap, stable test of something users care about scores high. A slow, flaky test of an internal detail scores near zero or below, because it slows every change and teaches people to ignore red builds.
The three jobs of a test suite
A test suite does three different jobs, and a good strategy keeps all three in view. Teams that think only about the first end up with slow, fragile suites.
- Catch regressions. A regression is something that used to work and now doesn't, like the checkout total that turned into
NaN. - Let you change code without fear. Good tests check behavior, not internals, so you can rewrite a component or upgrade React and learn whether users would notice. Tests that break on every refactor make people stop refactoring.
- Give fast feedback. A test that fails in 2 seconds on your laptop is worth far more than the same check failing 40 minutes later in CI.
These jobs pull against each other. The most realistic tests (a real browser, a real backend) are best at the first job and worst at the third. The fastest tests (a pure function in isolation) are best at the third job and miss most frontend bugs. A strategy is how you balance them.
Why frontend testing is its own problem
On a backend, much of the risk sits in logic: pricing rules, permissions, queries. On a frontend, most bugs are in the wiring: a prop passed wrong, a loading state that never ends, an error message that never shows, a button that works with a mouse but not with a keyboard, a layout that breaks only in Safari.
So backend testing advice does not carry over unchanged. This lesson builds a strategy for frontend code: the layers, the shapes people argue about, what each layer can see, what it costs, and what to test for each kind of component. Later lessons go deeper into each layer: [unit and integration tests](/system-design/concepts/unit-and-integration-tests), [end-to-end tests](/system-design/concepts/e2e-tests), and [contract and visual tests](/system-design/concepts/contract-and-visual-tests).
Check your understanding · Exercise 1
The shop had 1,400 tests and 82 percent coverage, yet the renamed prop broke checkout. What was the real gap? Select all that apply.
The mental model: how much of the real app runs
Every test sets something up, does something, and checks the result. What makes one kind of test different from another is how much of the real app runs during the test, and how much is replaced by a fake.
Picture a dial. At one end, the test runs a single function and fakes everything around it. At the other end, it runs everything for real: a real browser, the real build, the real servers. Turning the dial towards "real" catches more kinds of bugs, but every step costs speed and stability.
The layers, from cheapest to most real
- Static analysis. TypeScript and a linter such as ESLint read the code without running it, as you type. They catch typos, wrong prop types, a possibly
undefinedvalue used as if it were set, and auseEffectwith a missing dependency. - Unit tests. One function, hook, or reducer on its own, such as a price formatter or a cart reducer. They run in Node in a test runner like Vitest or Jest, in about 1 to 10 ms each.
- Integration tests. A component rendered with its real children, hooks, context, and state, in a simulated DOM (jsdom or happy-dom) or a real browser, with only the network faked. Frontend teams often call these component tests. They take roughly 20 to 300 ms each.
- End-to-end (E2E) tests. A real browser, driven by Playwright or Cypress, uses the real built app against a real or seeded backend: it clicks, types, and waits like a user. Each takes roughly 5 to 60 seconds.
- Beside the stack. Visual regression tests compare screenshots to catch changes in how things look. Contract tests check that the frontend and the API still agree on the shape of the data. Both are covered in [contract and visual tests](/system-design/concepts/contract-and-visual-tests).
These times are rough ranges for a typical React app on a CI machine; yours will differ. What holds everywhere is the ratio: each step up the dial is often 10 to 100 times slower than the one below it, sometimes more.
What is real and what is faked at each layer
The clearest way to tell the layers apart is to list what each one replaces with a fake. A fake is anything the test controls instead of the real thing: a mocked module, a fake fetch, a simulated DOM, a seeded database.
| Part of the app | Unit | Integration | End-to-end |
|---|---|---|---|
| Your function or hook | Real | Real | Real |
| Child components and context | Not rendered | Real | Real |
| The DOM and events | None | Simulated (jsdom) or a real browser | Real browser |
| CSS and layout | None | Mostly ignored in jsdom | Real |
| The network and API | Not used | Faked, for example with MSW | Real or a seeded test server |
| Routing, cookies, auth redirects | Not used | Usually faked | Real |
| Build output and deploy config | Not used | Not used | Real |
The table predicts what each layer misses. A unit test cannot see a bug in how two components connect, because only one is running. An integration test in jsdom cannot see a button hidden behind a sticky header, because jsdom does not compute layout. Only an E2E test can see a broken redirect after login, because only it runs the real auth flow.
The names are fuzzy; the dial is not
Teams use these words differently: one team's "unit test" renders a whole form, and another team calls that an integration test. Don't argue about the label. Ask instead what the test runs for real and what it fakes, because that tells you what it can catch.
Check your understanding · Exercise 2
A SearchBox test renders the component in jsdom with its real children and a fake API from MSW. What can it NOT catch? Select all that apply.
The pyramid, the trophy, and the honeycomb
The next question is how many tests to put in each layer. Three well-known shapes answer it, each drawn for a different kind of software at a different time, which tells you when each still fits.
The test pyramid
The test pyramid comes from Mike Cohn's 2009 book Succeeding with Agile and was made widely known by Martin Fowler's writing. It says: write many small unit tests at the bottom, fewer service or integration tests in the middle, and very few tests through the UI at the top.
The reason was cost. In the late 2000s, UI tests ran through tools like Selenium against slow browsers, broke whenever a page changed, and could take minutes each. Unit tests ran in milliseconds and rarely failed by accident. With those numbers, a pyramid is the cheapest way to get coverage.
Its best-known opposite is the ice cream cone: a few unit tests under a large pile of slow manual or E2E tests. Suites shaped like that take hours, fail randomly, and lose the team's trust.
The testing trophy
In 2018 Kent C. Dodds proposed the testing trophy for JavaScript apps, growing out of a line by Guillermo Rauch: "Write tests. Not too many. Mostly integration." From the bottom up, it has a wide base of static analysis, a thin layer of unit tests, the biggest layer of integration tests, and a small cap of E2E tests.
The argument has two parts. First, the more your tests resemble the way your software is used, the more confidence they give: users don't call your reducer, they click a button and read the screen. Second, the costs changed. With jsdom and Testing Library, rendering a whole form with its real children takes tens of milliseconds, so the old reason to avoid bigger tests mostly went away for components.
The testing honeycomb
The honeycomb came from Spotify's engineering blog in 2018, for backend microservices. A small service has little logic of its own; most of its risk is in how it talks to databases and other services. So the honeycomb puts most tests in the middle (integration tests of one service with its real dependencies), with a few "implementation detail" tests below and a few "integrated" tests across many services above.
For frontend engineers it is the trophy's idea reached from the other side of the API. It is also a good shape for any backend for frontend (a BFF, a small server that shapes data for one UI) your team owns.
Decision Which shape should a frontend test suite follow?
What we pick: For UI code, start from the trophy: a strict static base, most effort on integration tests of components and screens, and a short list of E2E tests. Use pyramid thinking inside it for pure logic, like a pricing engine, where many small unit tests are the right tool. Use the honeycomb for a backend for frontend.
Where the shapes agree
The shapes are more alike than the arguments suggest. All three keep E2E tests few and aimed at the journeys that matter most, and all three put the bulk of the effort where most bugs are. They only disagree about where the bugs are: logic-heavy code has them inside functions, UI code has them between components.
Check your understanding · Exercise 3
Which parts of a frontend codebase suit pyramid-style testing (many small unit tests)? Select all that apply.
What each layer catches, and what it misses
Choose between layers by starting from bugs, not tools. For each bug that could reach users, ask which is the cheapest layer that can even see it. This chapter walks through a dozen common kinds of frontend bug and where each one is caught.
Bugs the static layer catches
- A renamed prop. The checkout passes
amountto aPriceTagthat now expectsvalue. With typed props,tscfails the build in seconds. Without types, only a test that renders the checkout with the realPriceTagnotices. - A missing value used as if present. The API sometimes returns no
tagsfield, and the code callsproduct.tags.map(...). WithstrictNullCheckson and the API type sayingtags?: string[], TypeScript refuses to compile it. - A stale closure in an effect. A
useEffectreadsuserIdbut does not list it as a dependency, so it keeps fetching the first user. Thereact-hooks/exhaustive-depslint rule flags it as you type.
Bugs unit tests catch
- Rounding in money. The cart adds prices as floating point numbers (in JavaScript,
0.1 + 0.2is0.30000000000000004) and rounds each line before applying a discount, so the total is a cent away from the server's receipt. A unit test with a table of 20 price cases catches it in a few milliseconds. - An edge in a parser. A date range helper treats "last 7 days" as 8 days around a daylight saving change. A unit test with a fixed clock and a few tricky dates finds it.
Unit tests are excellent at logic with many cases and nearly blind to everything else. They never render the components that use the function, so they can't tell you whether it is called with the right data.
Bugs integration tests catch
- An error state that never shows. The orders API returns
500, and the page shows a spinner forever because the error branch sets the wrong state. An integration test makes the fake API fail and checks that the user sees "Couldn't load your orders" and a Retry button. - A keyboard trap. A dialog opens, but pressing Escape does nothing and focus escapes to the page behind it. A test with
user-eventpresses Escape and checks that the dialog is gone and focus is back on the button that opened it.
Here is such a test. It finds things the way a user or a screen reader would, by role and visible text, and never reaches into state or checks which hook was called.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { OrdersPage } from "./OrdersPage";
test("shows an error and retries when orders fail to load", async () => {
server.use(http.get("/api/orders", () => new HttpResponse(null, { status: 500 })));
render(<OrdersPage />);
expect(await screen.findByText("Couldn't load your orders")).toBeInTheDocument();
server.resetHandlers();
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("row", { name: /Order 1042/ })).toBeInTheDocument();
});
Bugs only a real browser and real servers catch
- A sign-in redirect loop. The session cookie is set with
SameSite=Strict, so it is not sent on the redirect back from the identity provider, and the app sends the user to sign in again, forever. Nothing below E2E runs real cookies across real origins. - A config that exists only in production. The production build reads
API_URLfrom an environment variable the deploy pipeline forgot to set. Every test against the dev server passes; a smoke E2E test against the deployed app catches it. - A browser-only bug. Checkout works in Chrome, but in Safari a CSS difference pushes the
Paybutton off the bottom of the screen. Only a run in a real WebKit engine sees it, and a screenshot makes it obvious.
Bugs the side layers catch
Some bugs slip past all four classic layers at a reasonable cost. When the backend team renames items to results, your integration tests keep passing, because the fake API still returns items. A contract test (for example with Pact), or validating responses with a schema at the boundary as shown in [API contracts and errors](/system-design/concepts/api-contracts-and-errors), catches it. When a CSS change shifts every card by 4 pixels, no assertion fails, but a visual regression test that compares screenshots does.
Check your understanding · Exercise 4
Pick the cheapest layer that reliably catches each bug. Which pairs are right? Select all that apply.
Cost and speed, with numbers
Every layer costs something different, and the costs add up in ways that are easy to miss. A test you write once may run tens of thousands of times over its life, on every save, pull request, and merge, so small numbers per run turn into hours per week.
The five costs of a test
- Writing it. A unit test of a pure function takes minutes. An integration test needs a render helper, providers, and fake API handlers, shared after the first few. An E2E test needs seeded data, sign-in, and a stable environment.
- Running it. Milliseconds for unit tests, tens to hundreds of milliseconds for integration tests, seconds to a minute for E2E tests, multiplied by every run.
- Keeping it working. A test that checks internals breaks on refactors users would never notice. These false alarms are the biggest hidden cost of many suites.
- Finding the bug when it fails. A failing unit test points at one function. A failing E2E test says "the checkout journey broke somewhere", and someone has to open a trace to find where.
- Flakiness. A test that sometimes fails with no code change costs re-runs, focus, and, worst of all, trust. A later chapter covers it.
A worked example: one pull request
Take a mid-sized app with this suite. The per-test times are rough averages; measure your own before planning around them.
| Layer | Count | Time each | Total, one worker | With parallel workers |
|---|---|---|---|---|
Static (tsc + ESLint) | whole repo | one pass | about 40 s | about 15 s with incremental caches |
| Unit | 1,200 | about 5 ms | about 6 s | a few seconds |
| Integration | 900 | about 150 ms | about 2.3 min | about 30 s on 8 workers |
| End-to-end | 40 | about 25 s | about 17 min | about 4 to 5 min on 4 machines |
Notice the shape: 40 E2E tests cost more time than 2,100 unit and integration tests together. Splitting them across machines (called sharding) helps, but each shard first installs dependencies, builds or downloads the app, and starts browsers, which can take a minute or more.
Why speed matters more than it looks
Speed decides when you learn about a bug, not just the CI bill. A type error shows up as you type, and a failing integration test in watch mode 2 seconds after you save, while the code is still in your head. A failing E2E test in CI shows up 5 to 15 minutes later, after you've opened another task, and costs a context switch on top of the fix.
Many teams set a budget, such as pull request checks finishing in under about 10 minutes. Staying inside it usually means running static checks, unit tests, and integration tests on every push, a small set of critical E2E tests on every pull request, and the wider E2E and visual suites on merge to main or nightly.
Try it: build a test mix
The lab below takes ten bugs from the previous chapter. First guess the cheapest layer for each. Then switch layers on and off and watch two numbers change: the bugs your mix catches, and the minutes it adds to every pull request.
1. Guess. For each bug, pick the cheapest layer that reliably catches it.
2. Build a mix. Switch layers on and off and watch bugs caught against minutes added to every pull request.
Two things stand out. Static checks plus integration tests catch half the list for under a minute of CI. The bugs about cookies, deploy config, real browsers, and the real API can only be bought with E2E, contract, or visual time, and each costs far more. That is the trophy's argument in numbers.
Check your understanding · Exercise 5
Your pull request checks take 28 minutes, mostly 140 E2E tests. Which changes cut the time without losing much confidence? Select all that apply.
Static analysis: the base of the trophy
Static analysis is the cheapest layer you have. It runs as you type, never flakes, and checks every line, including branches no test reaches. Many teams treat it as a style tool, but it belongs in the test strategy: whole classes of bugs can be made impossible before any test runs.
TypeScript: turn on the settings that catch bugs
TypeScript only checks what you let it check, so a project with loose settings and any everywhere gets little from it. These settings do most of the work:
strict: true. Turns on a family of checks, the most important beingstrictNullChecks: a value that might beundefinedmust be checked before use. This alone removes the "cannot read properties of undefined" family of crashes from typed code.noUncheckedIndexedAccess. Makesitems[0]have the typeItem | undefined, because the array might be empty. It is noisy at first and catches real crashes in lists and lookups.- No
anyat the edges. Ban explicitanywith a lint rule, and type third-party data asunknownuntil it is checked. Oneanysilently switches off checking for everything it touches.
Make wrong states impossible to write
The strongest use of types is not catching typos; it is shaping data so that bugs cannot be written at all. A request state written as two booleans and an optional value, isLoading, isError, and data, allows eight combinations, several of them nonsense (loading and errored at once). A discriminated union, a type with a status field that names each case, allows exactly the four real ones.
type Orders =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; orders: Order[] };
function render(state: Orders) {
switch (state.status) {
case "error":
return showError(state.message);
case "ready":
return showTable(state.orders); // orders exists only here
default:
return showSpinner();
}
}
orders only after checking status, so the "table with no data" bug cannot be written.Lint rules that act like tests
react-hooks/rules-of-hooksandreact-hooks/exhaustive-deps. Catch hooks called conditionally and effects that read values they do not list, the source of many stale-data bugs.@typescript-eslint/no-floating-promises. Flags a promise that is neither awaited nor handled, so a failed save is not silently lost. It needs type-aware linting, which is slower but worth it in CI.jsx-a11yrules. Catch an image with noalt, a click handler on adivwith no keyboard support, and a form field with no label. They find part of the accessibility problems, not all; see [testing accessibility](/system-design/concepts/accessibility-testing).
Where types lie: the network boundary
Types describe what your code believes, not what the server sends. When code writes const order = (await res.json()) as Order, TypeScript takes your word for it. If the API changes, the types still look fine and the bug appears three components later.
as Order skips the check; a schema checks the real data once, at the door, and gives you the type for free.The fix is to check data where it enters: parse the response with a schema library such as Zod or Valibot, and derive the TypeScript type from the schema. A wrong shape then fails loudly at the boundary instead of quietly deep inside the UI. This is the runtime half of a contract; [API contracts and errors](/system-design/concepts/api-contracts-and-errors) covers it in depth.
Check your understanding · Exercise 6
Which of these can static analysis alone catch, before any test runs? Select all that apply.
What to test for each kind of component
Different components carry different risks, so they need different mixes. Four common kinds cover most of a product: a design-system primitive, a data-heavy widget, a business-critical flow, and a visual component.
A design-system button
A Button in a shared design system is used in hundreds of places, so a bug spreads everywhere, but it has very little logic. Its risks are its props, its accessibility, and how it looks.
- Static: typed props, with variants as a union like
"primary" | "secondary" | "danger"so a typo fails the build. - Integration: a few tests that it renders a real
buttonelement, firesonClickon click and on Enter and Space, does not fire whendisabled, and has an accessible name when it is icon-only. - Visual: a screenshot per variant, size, and theme, usually from Storybook stories, so a CSS change that shifts every button is seen in review.
- Skip: E2E tests. Every E2E journey already clicks buttons.
A data table
A table with sorting, filtering, pagination, and row selection has real logic and many states. Its bugs are a wrong sort order, a filter that leaves you on a page that no longer exists, an empty state that never shows, and a header that screen readers don't announce.
- Unit: the sort and filter functions, with a table of cases: numbers vs strings,
nullvalues, locale-aware names ("Émile" next to "Emily"), and ties. - Integration: render with fake network data and act like a user: click a header and check the first row, go to page 2, filter, and check you're back on page 1. Check the loading, empty, and error states, and that the sorted header has
aria-sort. - Visual: a few screenshots for dense rows, long text, and narrow screens.
- E2E: usually none for the table itself; it appears inside one critical journey at most.
A checkout flow
Checkout is where the money is. It spans several pages, talks to payment and order services, and uses cookies, redirects, and often third-party scripts. A bug here costs revenue by the minute, so it gets the most realistic tests you have.
- Unit: the price, tax, and discount rules.
- Integration: each step on its own: the address form's validation, the error when the card is declined (fake API returns
402), what happens on a double click ofPay. - Contract: the order and payment API shapes, so a renamed field fails a check rather than a customer.
- E2E: one or two happy-path journeys from cart to confirmation, in Chromium and WebKit, against a test payment provider, plus a post-deploy smoke test of the same journey in production with a test account.
A chart
A chart's risk is in two places: turning raw data into points (scales, gaps, time zones), and what the user sees. Asserting on SVG path strings is a classic trap: such tests break on every library upgrade and prove nothing a person would notice.
- Unit: the data shaping: bucketing by day in the user's time zone, filling gaps, computing axis ticks.
- Integration: the parts a screen reader uses, such as the chart's accessible name, the data table fallback, and the tooltip text on keyboard focus.
- Visual: screenshots with fixed data for the main chart types and for edge cases like a single point, no data, and very large numbers.
| Component | Main risk | Most tests at | Also |
|---|---|---|---|
| Design-system button | Used everywhere; accessibility; looks | Visual + a few integration | Typed variants |
| Data table | Many states and interactions | Integration | Unit for sort and filter logic |
| Checkout flow | Revenue; cookies; real services | Integration per step | 1 to 2 E2E journeys, contract, smoke test |
| Chart | Data shaping; looks | Unit + visual | Integration for accessible parts |
Check your understanding · Exercise 7
You're adding a date range picker to the design system. Which tests give the best confidence for the cost? Select all that apply.
Flaky tests and what they really cost
A flaky test passes and fails on the same code: run it ten times and it might be green nine times and red once. Every team with E2E tests meets them, and how a team handles them decides whether people still trust a red build.
Small flake rates add up fast
One flaky test is an annoyance; many slightly flaky tests are a broken suite. If each test fails by accident with a small chance p, independently, a suite of n tests passes with a chance of (1 - p) to the power n.
Take 200 E2E tests that each flake once in 200 runs (0.5 percent). The suite passes cleanly only about 37 percent of the time (0.995 to the power 200), so almost two out of three pull requests go red for no reason. People learn to press "re-run" without reading the failure, and the day a real bug turns the build red, it gets re-run too.
Google's testing blog reported in 2016 that about 1.5 percent of its test runs gave a flaky result, and that almost 16 percent of its tests showed some flakiness. Those are one company's numbers; the lesson is that flakiness is normal at scale and has to be managed, not wished away.
Where flakiness comes from
- Timing. A fixed
sleep(500)that is enough on a laptop but not on a busy CI machine. Fix: wait for a condition, not a time. Playwright's web-first assertions retry until the page matches or a timeout passes. - Shared data. Two tests edit the same user's cart at once, or one leaves data another trips over. Fix: give each test its own data, created in setup through an API, not the UI.
- Order. A test passes only when another one ran first and left state behind. Fix: run tests in random order now and then to find these.
- The clock and the outside world. A test that checks "today" fails at midnight or in another time zone; one that calls a real third-party API fails when it is slow. Fix: pin the clock and fake third parties.
- Real bugs. Sometimes the app itself has a race, such as two requests answering out of order, and the test is right to fail. Retries hide exactly these bugs; see [request races](/system-design/concepts/request-races).
A policy that keeps the suite trusted
Good teams treat a flaky test as a bug with a life cycle. A common policy:
- Detect. Record every test result. A test that failed and then passed on a retry, on the same commit, is flagged as flaky automatically. Playwright reports these as "flaky" when retries are on.
- Quarantine. Move the test out of the blocking set within a day, so it stops failing other people's pull requests. It still runs and reports, it just doesn't block.
- Own and fix. Every quarantined test gets an owner and a deadline. Fix the cause, or delete the test if the same thing is covered elsewhere.
- Return or remove. A fixed test goes back into the blocking set. A test still flaky after the deadline is deleted. A test nobody trusts is worse than no test.
Check your understanding · Exercise 8
An E2E test for "add to cart" fails about once in 30 runs. Which steps are good next moves? Select all that apply.
Coverage: a weak signal, used well
Code coverage is the share of your code that ran during the tests. Tools such as Istanbul or V8's built-in coverage (both available in Vitest and Jest) report it per file: lines, statements, functions, and branches. It's easy to measure, so it's easy to turn into a target, and that's where the trouble starts.
What coverage tells you, and what it doesn't
Coverage tells you which code did not run. That is useful: a file at 0 percent has no tests at all, and an uncovered catch block means no test ever tried the failure path. Coverage cannot tell you whether the code that ran was checked. A test that renders the whole app and asserts nothing reaches high coverage and catches almost nothing.
test("checkout page", () => {
render(<CheckoutPage />);
// no assertions at all
});
// Coverage: CheckoutPage.tsx 88% lines. Bugs caught: close to none.
NaN total from the first chapter.Coverage also says nothing about what lives outside your source code. Unit and integration tests can cover 95 percent of lines and still miss the cookie, the deploy config, the API rename, and the Safari layout.
Why hard targets backfire
When a team must hit "90 percent coverage or the build fails", people write tests to raise the number: tests of getters, constants, and paths no user can reach. They cost maintenance, add little confidence, and turn the number into the goal instead of a hint.
Using coverage well
- Look for gaps, not totals. Scan the report for important files with low coverage, like checkout or auth, and for uncovered error branches.
- Check the coverage of the change. Some teams report coverage only for the lines a pull request changed (diff coverage). It asks "did you test what you just wrote?" without pressure to test old code.
- Use mutation testing for critical logic. A tool such as Stryker makes small changes to your code (flip a
>to>=, delete a line) and checks that some test fails. A surviving change shows code that runs without being checked. It is slow, so keep it to logic like pricing. - Track escaped bugs. The best measure of a test strategy is how many bugs reach users and which layer would have caught them. Coverage is only a hint towards that.
Check your understanding · Exercise 9
A team's coverage rose from 60 to 90 percent in a quarter, but the number of bugs reaching users stayed the same. What are likely reasons? Select all that apply.
Testing in a system design interview
Testing rarely gets its own question in a frontend system design interview, but it often comes up near the end: "How would you test this?" A short, structured answer is a strong senior signal, because it shows you think about the system's life after launch.
When to bring it up
- When the interviewer asks, or when you wrap up with "things I'd add next".
- When a decision creates a risk only tests can manage. If you chose optimistic updates, say how you'd test the rollback.
- For component questions (design an autocomplete, a date picker, a data table), testing is part of a complete answer: keyboard behavior, accessibility, and states.
A four-part answer
Keep it to about two minutes, and tie each part to the design you just drew. A good structure:
- The shape and why. "Mostly integration tests of components with the network faked by MSW, a strict TypeScript and lint base, unit tests for the ranking logic, and a few E2E tests."
- The critical journeys. Name the 2 to 4 E2E journeys for this product. For a news feed: load the feed, scroll to load more, like a post, and see it survive a refresh.
- The risky parts of this design. Point at your own diagram: "The cursor pagination and the optimistic like are where I'd put the most integration tests: duplicates across pages, rollback on a failed like, and two quick likes in a row."
- How it runs. What blocks a pull request, what runs after merge, and what runs after deploy. Mention flaky test handling in one sentence.
How deep to go by level
| Level | What a good answer covers |
|---|---|
| Junior / mid | Names the layers and what each catches; tests behavior through roles and text, not internals; one or two E2E journeys |
| Senior | Picks a shape for this product and says why; ties tests to the design's risky parts; CI budget and where each layer runs; contract or schema checks at the API |
| Staff | Treats tests as a team system: ownership of flaky tests, escaped-bug tracking, test data and environments, post-deploy checks and feature flags as a safety net alongside tests |
Production safety nets belong in a senior answer too. Tests reduce the chance of a bad release; monitoring, error tracking, feature flags, and fast rollback reduce the damage when one gets through. Saying both shows you know tests are only one part of shipping safely.
Check your understanding · Exercise 10
You designed a chat app with WebSockets and optimistic sending. Which testing points would strengthen your answer? Select all that apply.
Summary
- A test suite is a budget. Judge each test by the confidence it buys per minute of writing, running, and maintaining it.
- Tell layers apart by what runs for real and what is faked. Each step towards real catches more kinds of bugs and is often 10 to 100 times slower.
- For UI code, follow the trophy: a strict static base, a few unit tests for pure logic, most tests at the integration level, and a small set of E2E tests for critical journeys.
- Pick the cheapest layer that can see each bug. Types and lint catch whole classes of bugs for free; cookies, deploy config, and real browsers need E2E; API shape changes need contract or schema checks.
- Match the mix to the component: visual and accessibility tests for design-system pieces, integration tests for stateful widgets, E2E for revenue-critical flows, unit and visual tests for charts.
- Flaky tests compound: 200 tests at 0.5 percent flakiness fail most runs. Detect, quarantine, own, and fix them, and never let retries hide the list.
- Coverage shows what didn't run, not what was checked. Use it to find gaps, not as a target.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
How would you set up testing for a new frontend app your team is starting?
I'd start from the base: TypeScript in strict mode, ESLint with the React hooks, accessibility, and floating-promise rules, all running first in CI so they fail fast.
On top, most of the effort goes into integration tests: render real components and screens, act through roles and labels with Testing Library, and fake the network with MSW so the real fetch and error code runs. Pure logic such as pricing or date handling gets unit tests with tables of cases.
Then a short list of E2E tests in Playwright for the journeys that would get someone paged, like sign-in and checkout, in Chromium and WebKit. Contract or schema checks protect the API boundary, and visual tests cover the design system.
Pull request checks stay under about 10 minutes: static, unit, integration, and critical E2E. The wider E2E and visual suites run on merge and nightly, and a smoke test runs after each deploy.
Question 2
What's the difference between the test pyramid and the testing trophy, and which do you use?
The pyramid, from Mike Cohn and popularized by Martin Fowler, says most tests should be unit tests and only a few should go through the UI, because UI tests used to be slow and fragile.
The trophy, from Kent C. Dodds, adds static analysis as a wide base and puts the bulk of tests at the integration level, because frontend bugs mostly live between components and modern tools make component tests fast.
Both agree E2E tests should be few. I use the trophy for UI code and pyramid thinking for pure logic modules inside it. The real guide is where escaped bugs come from.
Question 3
Your E2E suite fails on about half of all pull requests, and people just re-run it. What do you do?
First, measure: record every test result and find which tests pass on retry with no code change. Usually a small number of tests cause most of the failures.
Quarantine those tests out of the blocking set right away, so the suite is trusted again, and give each one an owner and a deadline.
Fix the causes: replace fixed waits with web-first assertions, give each test its own data created through an API, fix the clock, and fake third-party services. Check whether some failures are real races in the app.
Finally, move checks that don't need a real browser, such as form validation, down to integration tests. A smaller E2E suite flakes less and runs faster.
Question 4
Is 90 percent code coverage a good goal?
Not as a goal. Coverage shows which code didn't run, which is useful for finding gaps, but it can't show whether the code that ran was checked. A test with no assertions can reach high coverage.
It also can't see bugs that aren't in your source lines: API changes, cookies, deploy config, browser quirks.
I'd use coverage to find untested important files and error branches, maybe report coverage on changed lines, use mutation testing for critical logic, and judge the strategy by the bugs that reach users.
Question 5
What would you test in a design-system Select component?
Its risks are accessibility, keyboard behavior, and looks, because it's used everywhere and has little business logic.
Integration tests: it opens on click and on Enter, Space, and arrow keys, moves through options with arrows and type-ahead, selects with Enter, closes on Escape and returns focus, has the right roles and an accessible name, and calls onChange once with the right value. An axe check in the same tests catches common ARIA mistakes.
Typed props so invalid combinations fail the build, and visual screenshots of the closed and open states in each theme. No E2E tests of its own; product journeys already use it.
Practice it
These design problems lean on a frontend testing strategy.
References
- The Practical Test Pyramid Ham Vocke on Martin Fowler's site: the pyramid in depth, with examples.
- The Testing Trophy and Testing Classifications Kent C. Dodds on the trophy's layers.
- Write tests. Not too many. Mostly integration. The argument for integration-heavy frontend suites.
- Testing of Microservices Spotify Engineering's post introducing the testing honeycomb.
- Flaky Tests at Google and How We Mitigate Them Numbers and policies for flaky tests at scale.
- Testing Library: Guiding Principles Why tests should resemble how software is used.
- Playwright: Best Practices Locators, web-first assertions, and test isolation.
Read next
- Unit and integration testsFast tests that check what users see and do: Testing Library queries, user-event, async UI without flakiness, mocking the network with MSW, and testing hooks, forms, and timers without coupling tests to how the code is built.
- End-to-end testsTests that drive a real browser through your real app like a user would: what to cover, how to keep them reliable, where test data comes from, and how to run them in CI.
- Contract and visual regression testsTests that guard the edges between teams: schema checks and consumer-driven contracts stop an API change that would break a client, and visual tests catch pixel changes nobody meant, from one button to a whole design system.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.
- API contracts, versioning, and errorsThe written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.