Senior Frontend Depth
Frontend Testing Strategy: Unit, Integration, E2E and What to Skip
Test what users see, not how the code is built. Mock the network, not your modules. Keep end-to-end tests for the key flows only.
On this page
- Key terms first
- The testing trophy
- React Testing Library's guiding principle
- Mock the network, not your own code
- Testing hooks and logic
- End-to-end with Playwright
- Snapshot tests: useful or noise?
- What to skip
- Scenario: tests break on every refactor. What's wrong?
- Keeping the suite healthy
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: test what a user can see and do, not how your code is organised. Find elements by role and text. Fake the network instead of your own modules. Keep end-to-end tests for the few journeys that must never break. If tests break on every refactor (a refactor means changing code structure without changing what it does), the tests have a design problem.
Backend testing is covered in Backend, Lesson 11. This lesson is the frontend half: React components, pages and flows.
Key terms first
- An automated test is code that runs your code and checks the result, so you do not check by hand. An assertion is one check inside a test, such as
expect(total).toBe(10). - A unit test is a test of one small piece (a function) alone. An integration test is a test of several pieces working together. An end-to-end (E2E) test is a test that uses the whole app in a real browser, like a user. Difference in one line: unit tests check a part, integration tests check how parts connect, and E2E tests check the whole journey.
- A test runner is a tool that finds and runs your tests and reports the results. Vitest and Jest are test runners for JavaScript.
- React Testing Library (RTL) is a library that renders React components in a test and gives you ways to find and use elements like a user would.
- MSW (Mock Service Worker) is a library that catches network requests in tests and answers them with fake responses.
- Playwright is a tool that controls a real browser (Chromium, Firefox, WebKit) from code, so you can write E2E tests.
- A mock is a fake stand-in for a real thing, used in a test.
vi.fn()makes a mock function that records how it was called. - Test coverage is the percentage of your code lines that ran during the tests.
- A race (race condition) is a bug where the result depends on which of two things finishes first.
The testing trophy
▲ E2E a handful: login, checkout, the revenue paths ███ Integration the bulk: pages/features with real components, mocked network █████ Unit pure logic: formatters, reducers, hooks with tricky logic ███████ Static TypeScript + ESLint catch a whole class of bugs for freeThe "trophy" is a picture of how to spread your testing effort. It is different from the older "pyramid", which puts most tests at the unit level. The trophy puts most effort into integration tests. Here, you render a feature the way a user meets it. You use real child components, real state management and a fake network. These tests give the most confidence for each test you write. The reason is that bugs usually live in the wiring (the connections) between pieces.
| Level | Tools | Tests | Speed |
|---|---|---|---|
| Static | TypeScript, ESLint (react-hooks, jsx-a11y) | Types, hook rules, accessibility lint | Instant |
| Unit | Vitest or Jest | Pure functions, reducers, selectors, custom hooks | Milliseconds |
| Integration / component | Vitest or Jest + React Testing Library + MSW | Features and pages from the user's point of view | Tens of ms each |
| End-to-end | Playwright (or Cypress) | Full app in a real browser | Seconds each |
A few words in the table need a short explanation:
- A pure function always gives the same output for the same input. It changes nothing outside itself.
- A reducer is a function that takes the old state and an action, and returns the new state.
- A selector is a function that picks or works out a small piece of data from a larger state.
- Lint means a tool that reads your code and warns about likely mistakes. ESLint is the common one for JavaScript.
react-hooksis a rule set that checks the rules of hooks.jsx-a11yis a rule set that checks accessibility (a11y) problems in JSX. - A custom hook is a function you write that uses React hooks inside it, so you can reuse that logic.
- jsdom and shim are explained below, in the section on mocking.
React Testing Library's guiding principle
The more your tests resemble the way your software is used, the more confidence they can give you.
In practice, this means four things:
- Find elements the way a user (or a screen reader) would. Use this order of preference:
getByRole(with an accessible name),getByLabelText,getByPlaceholderText,getByText,getByDisplayValue, thengetByAltTextorgetByTitle. UsegetByTestIdonly as a last resort. (A screen reader is a tool that reads the page aloud for people who cannot see it. An accessible name is the text it reads out for an element.) - Interact the way a user would.
@testing-library/user-eventis a package that acts out typing, clicking and tabbing in a realistic way. It sends focus, key and pointer events. Calling handler functions directly does not do this. - Check what the user sees. Look at text on screen, enabled or disabled state,
aria-*states and navigation. Do not check component state or private functions. - Avoid testing implementation details. Implementation details are the inside parts of the code. Examples are internal state, the return values of a component's hooks, which child got which prop, and class names.
getByRole has a bonus. Suppose you cannot find your element by role and name. Then a screen-reader user probably cannot find it either. That is a real accessibility bug (Lesson 06).
A component test
// Assumes Vitest with `globals: true` (otherwise import test, expect and vi from "vitest")// and the jest-dom matchers (toBeInTheDocument, toHaveTextContent) set up once in a setup file.import { render, screen } from "@testing-library/react";import userEvent from "@testing-library/user-event";import { LoginForm } from "./LoginForm";test("shows a validation error and then submits valid credentials", async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(<LoginForm onSubmit={onSubmit} />); await user.click(screen.getByRole("button", { name: /sign in/i })); expect(await screen.findByText(/email is required/i)).toBeInTheDocument(); // async UI: findBy waits expect(onSubmit).not.toHaveBeenCalled(); await user.type(screen.getByLabelText(/email/i), "ada@example.com"); await user.type(screen.getByLabelText(/password/i), "correct horse"); await user.click(screen.getByRole("button", { name: /sign in/i })); expect(onSubmit).toHaveBeenCalledWith({ email: "ada@example.com", password: "correct horse" });});A query is a function that finds an element on the screen. Notes on the three query types:
- Use
findBy*for things that appear later. It is async (it returns a Promise, an object for a result that arrives later, and youawaitit), and it tries again until the element shows up or a time limit (1 second by default) passes. - Use
queryBy*to check that something is absent. It returnsnullif nothing matches. Example:expect(screen.queryByText("x")).not.toBeInTheDocument(). - Use
getBy*for things that must exist right now. It throws an error if nothing matches, and also if more than one element matches. - Difference in one line:
getBy*expects the element now,queryBy*allows it to be missing, andfindBy*waits for it to appear.
waitFor is a helper that runs a check again and again until it stops failing. Use it only when you really need it. Never put side effects inside it. (A side effect is an action that changes something, like sending a request.)
Mock the network, not your own code
A mock is a fake version of something, used only in tests. You can mock your own modules and hooks, for example vi.mock("./useOrders"). You can also use shallow rendering (render one component without its children) or replace child components. All of these tie your tests to how the code is built.
Do this instead. Mock the boundary you do not control, which is HTTP. MSW catches requests and sends back fake responses. Then your components, hooks, data library and error handling all run for real.
import { http, HttpResponse } from "msw";import { setupServer } from "msw/node";const server = setupServer( http.get("/api/orders", () => HttpResponse.json([{ id: "o1", total: 4999, status: "paid" }])),);beforeAll(() => server.listen({ onUnhandledRequest: "error" })); // fail on requests you forgot to mockafterEach(() => server.resetHandlers());afterAll(() => server.close());test("lists orders from the API", async () => { render(<OrdersPage />, { wrapper: AppProviders }); // real router, query client, theme… expect(await screen.findByRole("row", { name: /o1/ })).toBeInTheDocument();});test("shows an error state when the API fails", async () => { server.use(http.get("/api/orders", () => new HttpResponse(null, { status: 500 }))); // per-test override render(<OrdersPage />, { wrapper: AppProviders }); expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't load orders/i);});Why this is good:
- The same handlers work in tests, in Storybook and in local development.
- Error and loading paths are easy to cover.
- You can change the fetching layer (for example, React Query to SWR) and the tests still pass.
Also keep a custom render function that wraps your providers. Providers are the shared wrappers an app needs, such as the router, the query client (with retries turned off, so a failed request fails at once in a test), the theme and i18n (internationalisation, meaning translations).
Sometimes you should mock more. Good cases are:
- Third-party widgets that need a real browser, such as maps and charts.
- Time, with
vi.useFakeTimers(). - Randomness.
- Shims for
IntersectionObserver(a browser API that tells you when an element enters the screen) andmatchMedia(a browser API that checks CSS media queries) in jsdom. (jsdom is a fake browser that runs in Node. A shim is a small piece of code that adds a missing feature.) - Anything that gives a different result each run.
Testing hooks and logic
- Pure logic (formatters, validation, reducers, selectors): write plain unit tests with table-driven cases. Table-driven means one test runs over a list of inputs and expected outputs.
- Custom hooks with real complexity: use
renderHookfrom Testing Library, a helper that runs a hook inside a small test component and gives you its result. Still, it is better to test the component that uses the hook. That also proves the pieces work together. - Reducers and state machines: test each step from a starting state plus an event to the new state.
test.each([ [{ status: "idle" }, { type: "fetch" }, { status: "loading" }], [{ status: "loading" }, { type: "ok", data: [1] }, { status: "success", data: [1] }],])("reducer %j + %j → %j", (state, action, expected) => { expect(reducer(state as State, action as Action)).toEqual(expected);});End-to-end with Playwright
E2E tests drive a real browser against a real stack, or a staging copy of it. They are the slowest and the most fragile. So use them only for critical journeys. Examples are sign up, log in, search then add to cart then pay, and anything that loses money or trust.
import { test, expect } from "@playwright/test";test("a signed-in user can place an order", async ({ page }) => { await page.goto("/products/blue-shirt"); await page.getByRole("button", { name: "Add to cart" }).click(); await page.getByRole("link", { name: "Cart (1)" }).click(); await page.getByRole("button", { name: "Checkout" }).click(); await page.getByLabel("Card number").fill("4242 4242 4242 4242"); await page.getByRole("button", { name: "Pay" }).click(); await expect(page).toHaveURL(/\/orders\/\w+/); await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible();});A locator is a Playwright object that describes how to find an element, and it looks the element up fresh each time it is used. Locators wait automatically until the element is ready. Web-first assertions, like expect(locator).toBeVisible(), are checks that also try again until they pass or time out. This removes most flakiness that comes from fixed sleeps. (A flaky test sometimes passes and sometimes fails with no code change.)
How do you test an auth flow end to end?
- Test the login screen once, end to end. Check the form, the errors and the redirect.
storageStateis a Playwright feature that saves a browser's cookies and local storage to a file. For every other test, log in with code. Call the login API, or create a session in the setup step. Then reuse the savedstorageState(the cookies and local storage). Each test then starts already logged in, without repeating the screen steps. In Playwright, a setup project savesstorageStateto a file, and other projects load it.- Create test users and data through an API or a database fixture (a fixed set of data prepared before a test). Use a separate test environment and keep data isolated, for example a unique email for each run.
- Mock or sandbox third parties (a sandbox is a safe test copy of a real service). Examples are the test mode of a payment provider and a fake email inbox such as Mailpit, a tool that catches emails your app sends.
- Cover negative paths (wrong password, locked account) in lower-level tests. This keeps the E2E suite small.
- Also test authorization limits. (Authentication means proving who you are. Authorization means checking what you are allowed to do.) A logged-out user must be redirected. A user must not see another user's data (Backend, Lesson 06).
Snapshot tests: useful or noise?
A snapshot test saves an output (for example, the DOM as text, or a value). It fails when that output changes later.
- Useful: small, stable outputs that mean something. Examples: a formatted string, a generated config, the shape of an API response, a serialised data structure (data turned into text), a set of error messages.
- Noise: large DOM snapshots of whole components. They break on every harmless markup change. Reviewers click "update" without reading. They also check structure, not behaviour. Use clear, explicit assertions instead.
- Good practice: keep snapshots small. Use inline snapshots, so the expected value is visible inside the test. Read every diff carefully.
Other tools you can use:
- Visual regression tests (Playwright screenshots, Chromatic) take a picture of the UI and compare it with an approved picture. Use a small tolerance and a stable environment.
- Accessibility checks (
jest-axe,@axe-core/playwright) run axe, a tool that finds common accessibility problems, inside tests. - Component stories (Storybook is a tool that shows each component in isolation, in several states) can include interaction tests.
What to skip
- Implementation details: internal state, private helpers, which function was called, props passed to children.
- Third-party libraries: do not test that React Query caches or that
date-fnsformats dates. Trust them and pin their version (fix it to an exact version number). - Trivial components with no behaviour, such as a styled wrapper.
- Styling and exact markup (class names, pixel values). The exception is visual tests.
- 100% coverage everywhere. Coverage shows which lines ran. It does not show which lines were checked. Aim for confidence on critical paths and edge cases.
- The same behaviour at every level. Test a rule at the lowest level that gives you confidence. Then prove the pieces work together once, higher up.
Scenario: tests break on every refactor. What's wrong?
The tests are tied to the implementation, not the behaviour. Here are the usual causes and fixes:
| Smell | Fix |
|---|---|
| Asserting on internal state or hook return values | Assert on what renders and what the user can do |
| Shallow rendering or mocking child components | Render the real tree |
vi.mock of your own modules and hooks | Mock the network (MSW) at the boundary |
Selectors by class name, DOM structure or index (container.querySelector(".btn-primary > span")) | getByRole/getByLabelText with accessible names |
| Large DOM snapshots | Targeted assertions |
Asserting toHaveBeenCalledTimes on internals | Assert on observable outcomes |
| One giant test per component | Several small behaviour-focused tests |
A healthy test suite lets you rewrite a component's inside completely and stay green. For example, you can change a class to hooks, or Redux to React Query. This works as long as the behaviour stays the same.
Keeping the suite healthy
- Run tests in CI (continuous integration, an automatic check on a server) on every pull request. Fail the build when a test is red. Keep the main suite fast, under a few minutes.
- Treat flaky tests as bugs. Find the race (two things happening in an unexpected order). It is usually a missing
awaitorfindBy, shared state or time. Then fix the test or delete it. - Keep tests separate from each other. Use no shared mutable state. Reset handlers. Clean up after each test (RTL cleans up automatically after each test when your runner provides global
afterEach, as Jest does. In Vitest, setglobals: trueor callcleanupyourself). Use unique data in E2E. - Name tests by behaviour, for example "shows an error when the API fails".
- Test the unhappy paths: loading, empty, error, offline and permission denied.
- Control time and randomness. Use
vi.useFakeTimers, fixed random values, and a fixed locale and timezone. - Use TDD (test-driven development: write a failing test first, then write the code that makes it pass) where it helps. It helps for code with a lot of logic, and for reproducing a bug first. Do not follow it blindly.
Common mistakes
- Testing implementation details (state, props passed down, method calls).
- Using
getByTestIdeverywhere instead of role and label queries. - Mocking your own modules instead of the network.
- Giant snapshots that nobody reads.
- Too many E2E tests (slow and flaky) and none at lower levels.
- Not awaiting async UI, then adding
setTimeouthacks. - Testing only the happy path.
- Chasing coverage numbers.
Interview questions
What do you test in a React component?
I test behaviour that a user can see. I check what renders for given props and data. I check how it reacts to typing, clicks and keyboard input. I check the loading, empty and error states, and the accessibility roles and names. I also check the results it causes, such as a request being sent, a page change or a callback being called. I do not check internal state or structure.
Scenario: tests break on every refactor. What's wrong?
The tests are tied to the implementation. Common causes are shallow rendering, mocked internals, selectors by class name or DOM structure, checks on internal state and large snapshots. I would rewrite them. I would find elements by role and text, mock only the network, check visible results and keep snapshots small. Then a refactor that keeps the behaviour keeps the tests green.
How do you test an auth flow end to end?
I test the login screen once in E2E. After that, I log in with code in the setup step and reuse the saved storageState in the other tests. I create isolated test data and sandbox third parties. I cover the negative cases and the authorization limits at lower levels. I keep the E2E suite focused on critical journeys.
Are snapshot tests useful or noise?
Both. Small, stable outputs that mean something are good for snapshots, and inline snapshots are best. Large DOM snapshots are mostly noise. They break on harmless changes and people approve them without review. For behaviour, I prefer explicit assertions. For looks, I use visual regression tools.
Why mock the network instead of modules?
When I mock at the HTTP boundary, the real components, hooks, caching library and error handling still run. So the tests check that the pieces work together, and they survive changes to the data layer. Module mocks tie tests to the internal structure. They can also hide bugs in how the pieces connect.
getByRole vs getByTestId?
getByRole (with an accessible name) matches how users and assistive technology find elements. It also works as an accessibility check. getByTestId is a last resort for elements that have no accessible role. Users cannot see a test id, and it leads to tests that are far from real behaviour.
How do you handle async UI in tests?
I use findBy* queries or waitFor to wait for elements to appear. I await user-event actions. I give fixed, predictable network responses with MSW. I use fake timers for timed behaviour. I avoid random sleeps. In E2E, Playwright's auto-waiting locators and web-first assertions handle this.
What belongs in E2E tests?
Only the critical user journeys that must never break and that need the full stack. Examples are sign-in, search, checkout and payment. I also add a few smoke tests (quick checks that the app starts and the basics work) for each deployment, and the essential cross-browser checks. Detailed logic and edge cases belong in faster unit and integration tests.
Scenario: a test passes locally but fails in CI. How do you investigate?
I look for differences in timing, environment and shared state. Possible causes are a missing await or findBy (CI is slower), different time zones or locales, random or order-dependent tests, state leaking between tests, different Node or browser versions, and resource limits. I reproduce it with the same container. I run the test many times and alone. I use traces or videos. I fix the root cause and I avoid sleeps.
Key takeaways
- Trophy: static checks, many integration tests, some unit tests and a few E2E tests.
- Find elements by role, label and text. Use user-event. Check what the user can see.
- Mock the network (MSW), not your own modules. Use a custom render with providers.
- Auth in E2E: test login once, then reuse
storageState. Create isolated data. - Use small, meaningful snapshots only. Skip implementation details, third-party internals and coverage chasing.
- Tests that survive refactors focus on behaviour. Flaky tests are bugs.
What's next
Lesson 06: Accessibility Essentials. This is the last lesson. It covers semantic HTML, keyboard support, focus management and ARIA. Senior interviews check these topics, and your tests can enforce them.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Testing an API: Unit, Integration and Contract · Backend in Depth
- Patterns, Server Components and Testing React · React in Depth
- Building an Evaluation Harness for an LLM Feature · AI Engineering
Part of the study guide
- TypeScript, React and Next.js Guide for Developers
- Software Testing Guide: Unit, Integration and E2E Tests
Related series