Frontend testing
The testing pyramid and the testing trophy
Two shapes describe how to spread tests across a codebase. The pyramid (Mike Cohn, popularized by Martin Fowler) stacks many fast unit tests at the base, fewer integration tests in the middle, and a thin cap of slow end-to-end tests. The rule of thumb: the higher you go, the fewer tests you keep.
The testing trophy (Kent C. Dodds) holds the same caution about end-to-end but shifts the bulk to integration tests, and adds a layer the pyramid skips: static checks (linting and type checking) that catch typos and type errors before a test runs. Its mantra is "Write tests. Not too many. Mostly integration."
Both are heuristics about cost. A test higher in the stack gives more confidence that the whole app works, but runs slower and breaks for more unrelated reasons.
Unit, integration, and end-to-end
- A unit test checks one piece in isolation, its collaborators replaced by mocks. It is fast and points straight at what broke, but it can pass while the wiring between units is broken.
- An integration test runs several pieces together. On the frontend this usually means rendering a feature with React Testing Library, mocking only the network, and clicking through it. It catches wiring bugs a unit test misses.
- An end-to-end test drives the whole app in a real browser with a tool like Playwright, close to what a person does. It gives the most confidence and costs the most time, so you keep only a few.
What React Testing Library tests
The guiding principle is one sentence: "The more your tests resemble the way your software is used, the more confidence they can give you." So RTL hands you the rendered DOM, not the component instance. You find elements the way a user would, by role and visible text, and assert on what appears on screen.
// find the button by its role and name, click it, check the result
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText('Welcome back')).toBeInTheDocument();
That is why the top-recommended query is getByRole. A test written this way survives an internal refactor and fails only when the behavior a user relies on changes.
getBy, queryBy, findBy
Three prefixes, three jobs:
- getBy finds an element now and throws a readable error if it is missing. Use it to assert presence.
- queryBy returns null instead of throwing when nothing matches. Use it to assert absence.
- findBy returns a promise and waits, up to a timeout, for an element to appear. Use it after an async step.
expect(screen.queryByText('Error')).not.toBeInTheDocument(); // absent
expect(await screen.findByText('Saved')).toBeInTheDocument(); // appears later
Query priority: getByRole first, test IDs last
Testing Library ranks its queries so a test resembles how the app is used. Top of the list is getByRole, which finds an element in the accessibility tree by its role and accessible name: getByRole('button', { name: /save/i }). Below it come getByLabelText for form fields, then getByPlaceholderText, getByText, and getByDisplayValue. Semantic queries like getByAltText sit lower. Dead last is getByTestId.
Wrong "getByTestId is the clean, stable way to select elements." A data-testid is invisible to users, so a passing testid query proves nothing about the UI they see. It is the documented escape hatch for cases where nothing else can reach the element (dynamic or canvas content). Right reach for getByRole first and drop to a test id only when no accessible query works.
Prefer userEvent over fireEvent
fireEvent dispatches a single raw DOM event with no realism checks. userEvent (a separate package) simulates the whole interaction: it moves focus, fires the keydown/keypress/input/keyup sequence a real keystroke produces, and checks the element is visible and enabled first. Since v14 you set it up once and await every interaction.
const user = userEvent.setup();
render(<Form />);
await user.type(screen.getByLabelText('Email'), 'a@b.com'); // real keystrokes
Wrong "fireEvent.change(input, { target: { value: 'a@b.com' } }) is the same as typing." It sets the value in one shot and skips focus, the key events, and the interactability checks, so it can pass where a real user would be blocked.
getBy for presence, queryBy for absence, findBy for async
Only findBy and findAllBy are asynchronous. getBy and queryBy are synchronous, so awaiting a getBy buys you nothing.
- Assert present:
getByText('Saved'), which throws a readable error if it is missing. - Assert absent:
expect(queryByText('Error')).not.toBeInTheDocument(), since queryBy returns null instead of throwing. - Assert it appears later:
await findByText('Saved'). findBy is getBy plus waitFor under the hood, retrying until the element shows up (default 1000ms timeout, 50ms interval).
Wrong "queryBy is the general-purpose query." queryBy earns its keep on absence checks. Use getBy for presence so a missing element gives a helpful failure, and put a single assertion (no side effects) inside any waitFor, which re-runs the callback until it stops throwing.
Mock at the network boundary, not the module
Module mocks (jest.mock('axios'), vi.mock('./api')) replace a module in the JS graph, so they are tied to how the code fetches: swap axios for fetch and they break though behavior is unchanged. MSW intercepts at the network layer instead, a Service Worker in the browser and request interceptors in Node. Handlers match a URL and method, so one mock serves whatever client the app uses and the app code stays untouched.
// serves fetch, axios, or a query library alike
http.get('/api/user', () => HttpResponse.json({ id: 'abc-123' }));
In Node tests you run a server around the suite: server.listen() in beforeAll, server.resetHandlers() in afterEach so per-test overrides do not leak, server.close() in afterAll.
Wrong "MSW patches your fetch or axios." It leaves your client alone and intercepts the request after your code sends it, which is why it is client-agnostic.
Testing implementation details
Implementation details are the things a user of your code never sees: internal state variable names, private methods, instance internals. Asserting on them breaks a test in two directions. A false negative is renaming openIndex to openIndexes while behavior is identical, and the test fails anyway. A false positive is asserting an internal flag flipped, and the test staying green even though the click handler was never wired and the app is broken. RTL exposes DOM and role queries and hides the instance to steer you away from both.
Wrong "Reaching into state and private methods is more thorough coverage." It couples the test to the shape of the code, so refactors cost you failing tests while real breakage slips through green ones. Assert through the public interface: rendered output, the roles and text a user reads, the props you pass in.
Why tests go flaky
A flaky test passes and fails on the same code. In the canonical study of the problem (Luo et al., 2014, across 51 open-source projects), the leading causes were waiting on something asynchronous (about 45%), concurrency (about 20%), and test-order dependency (about 12%). The frontend versions map straight onto those:
- A fixed
sleep(500)guesses how long an async step takes, and slow CI blows the guess. Wait for the condition: findBy, waitFor, or a Playwright web-first assertion. - Shared mutable state lets test A seed something test B silently depends on, so reordering or parallelism fails. Reset between tests with a fresh render,
server.resetHandlers(), and per-test fixtures. - Time and randomness (
Date.now,Math.random) differ per run. Fake the clock, seed the RNG, or match withexpect.any(Date).
E2E strategy with Playwright
E2E gives the highest confidence at the highest cost, so keep the suite small and aimed at the critical journeys: login, checkout. You do not cover every permutation.
The defining feature is web-first assertions that auto-wait and auto-retry until the condition holds.
await expect(page.getByText('Welcome')).toBeVisible(); // retries
expect(await page.getByText('Welcome').isVisible()).toBe(true); // checks once, flaky
Locate by user-facing handles (getByRole, getByLabel, getByText); CSS selectors rot on the next restyle. Each test runs in its own BrowserContext, an incognito-like profile with isolated cookies and storage, so tests parallelize without contaminating each other. Retries deserve care: Playwright labels a test flaky when it fails then passes on retry, which surfaces the flake and keeps CI moving. A flaky result is still a defect to fix, and treating it as a passing test hides a real intermittent bug.
Snapshot overuse and what not to test
Snapshots catch unexpected changes in serializable output, but they complement explicit assertions rather than replacing them. The failure mode is rubber-stamping: a test fails, someone regenerates the snapshot without reading it, and wrong output gets blessed. Keep them small and reviewed like code, make them deterministic with property matchers (toMatchSnapshot({ createdAt: expect.any(Date) })), and note that CI will not write a snapshot without an explicit --updateSnapshot.
Some things earn no test at any layer: third-party libraries and services you do not own (mock the boundary), framework guarantees like React re-rendering, and the same behavior duplicated across every layer. Test each behavior at the cheapest layer that gives confidence.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.