Intermediate to senior

Frontend Interview Prep

Fourteen chapters on HTML and CSS, core JavaScript, the event loop, browser rendering, React, state, performance, accessibility, security, TypeScript, testing, machine-coding components and frontend system design, with tested code.

Chapter 12 of 14Quality and security · Frontend Testing Strategy

Frontend Testing Strategy

Testing questions check whether you know what to test, at which level, and how to keep tests useful as the code changes. Candidates who list tools impress less than those who explain a strategy: which risks each kind of test covers, why tests should follow user behaviour rather than implementation, and how to avoid flaky suites. This chapter covers the levels, the principles, and the techniques (mocking, async, accessibility-driven queries), with runnable examples.

1. The levels

LevelWhat it checksSpeedTypical tools
Static analysistypes, lint rules, formattinginstantTypeScript, ESLint, Prettier
Unitone function or module in isolationvery fastVitest, Jest, Node test runner
Component / integrationa component or feature with real children, in a simulated DOMfastTesting Library with Vitest or Jest
End-to-end (E2E)the real app in a real browser against a (test) backendslowPlaywright, Cypress
Visual regressionscreenshots against baselinesmediumPlaywright, Chromatic, Percy
Accessibilityrules and flowsfast to mediumaxe, jest-axe, Playwright with axe
Performancebudgets, Web VitalsvariesLighthouse CI, bundle-size checks

The testing trophy (a refinement of the testing pyramid) puts most weight on integration tests: they give high confidence per test, because they exercise components together the way users meet them, while static analysis catches a large share of mistakes for free. Keep a small number of E2E tests for critical user journeys (sign up, checkout), because they are slow and prone to flakiness.

Ask of every test: what failure would this catch that matters to a user?

2. Principles

  1. Test behaviour, not implementation. Assert what the user sees and does, not internal state, private functions, or how many times something rendered. Refactoring should not break tests unless behaviour changed.
  2. Arrange, act, assert. Set up, perform one action, check the outcome.
  3. One reason to fail. Name tests by behaviour: "shows an error when the email is invalid".
  4. Independent and deterministic. No shared mutable state between tests; control time, randomness and network.
  5. Fast feedback. A suite people avoid running protects nothing.
  6. Do not chase 100% coverage. Coverage shows what is not tested, not that the tested parts are correct. Aim for confidence on risky paths.

3. Unit tests: pure logic first

The easiest, highest-value tests are for pure functions: formatters, reducers, validators, selectors.

const test = (name, fn) => { try { fn(); } catch (e) { e.message = `${name}: ${e.message}`; throw e; } };

function formatPrice(paise) {
  if (!Number.isInteger(paise) || paise < 0) throw new RangeError("invalid amount");
  const rupees = Math.floor(paise / 100), rest = String(paise % 100).padStart(2, "0");
  return `Rs ${rupees.toLocaleString("en-IN")}.${rest}`;
}
test("formats whole rupees", () => assert.equal(formatPrice(150000), "Rs 1,500.00"));
test("keeps leading zero in paise", () => assert.equal(formatPrice(1005), "Rs 10.05"));
test("rejects negatives", () => assert.throws(() => formatPrice(-1), RangeError));
test("rejects fractions", () => assert.throws(() => formatPrice(1.5), RangeError));
test("handles zero", () => assert.equal(formatPrice(0), "Rs 0.00"));

Table-driven tests keep many cases compact and make gaps visible:

const cases = [
  [0, "Rs 0.00"], [99, "Rs 0.99"], [100, "Rs 1.00"], [123456789, "Rs 12,34,567.89"],
];
for (const [input, expected] of cases) assert.equal(formatPrice(input), expected, `formatPrice(${input})`);

Test boundaries and edges: empty, one item, maximum, duplicates, invalid input, time zones and locale where relevant.

4. Component and integration tests

Render the component, interact like a user, and assert what is visible. Query by role and accessible name first, then label text, then text; use test IDs only as a last resort. This both finds elements the way users and screen readers do and nudges you toward accessible markup.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test("adds an item to the cart and shows the count", async () => {
  const user = userEvent.setup();
  render(<ProductPage product={{ id: "p1", name: "Notebook", price: 12000 }} />);

  await user.click(screen.getByRole("button", { name: /add to cart/i }));

  expect(screen.getByRole("status")).toHaveTextContent("1 item in cart");
});

test("shows an error message when the email is invalid", async () => {
  const user = userEvent.setup();
  render(<SignupForm onSubmit={jest.fn()} />);

  await user.type(screen.getByLabelText(/email/i), "not-an-email");
  await user.click(screen.getByRole("button", { name: /sign up/i }));

  expect(await screen.findByRole("alert")).toHaveTextContent(/valid email/i);
});

Points to know:

  • getBy* throws if missing (use for things that must exist), queryBy* returns null (use to assert absence), findBy* waits asynchronously (use for things that appear later).
  • Prefer userEvent (full realistic interaction sequences) over fireEvent.
  • Do not assert on implementation details such as state values, component names or class names.
  • Wrap providers (router, query client, theme) in a custom render helper.
  • Do not test the framework or third-party libraries; test your behaviour.

5. Mocking and test doubles

DoublePurpose
Stubreturns canned data
Fakea lightweight working implementation (an in-memory store)
Spywraps the real function and records calls
Mocka stub plus expectations on how it was called

Mock at the boundary: the network (use Mock Service Worker to intercept requests at the network layer and keep component code unchanged), time, randomness, and browser APIs that jsdom lacks (IntersectionObserver, matchMedia). Over-mocking (mocking your own modules and children) makes tests pass while the real thing is broken.

// dependency injection makes a function testable without a network
async function loadProfile(fetchJson, id) {
  const data = await fetchJson(`/api/users/${id}`);
  return { ...data, displayName: data.name.trim() || "Anonymous" };
}
const calls = [];
const fakeFetch = async url => { calls.push(url); return { id: 7, name: "  Asha " }; };
const profile = await loadProfile(fakeFetch, 7);
assert.equal(profile.displayName, "Asha");
assert.deepEqual(calls, ["/api/users/7"]);
assert.equal((await loadProfile(async () => ({ name: "   " }), 1)).displayName, "Anonymous");

Controlling time

Real timers make tests slow and flaky. Use fake timers and advance them explicitly. The idea in miniature:

function createFakeClock() {
  let now = 0, id = 0; const timers = new Map();
  return {
    setTimeout(fn, ms) { const t = ++id; timers.set(t, { at: now + ms, fn }); return t; },
    clearTimeout(t) { timers.delete(t); },
    tick(ms) {
      const target = now + ms;
      for (;;) {
        const due = [...timers].filter(([, v]) => v.at <= target).sort((a, b) => a[1].at - b[1].at)[0];
        if (!due) break;
        now = due[1].at; timers.delete(due[0]); due[1].fn();
      }
      now = target;
    },
  };
}
const clock = createFakeClock();
const fired = [];
function debounceWith(clk, fn, wait) { let t; return v => { clk.clearTimeout(t); t = clk.setTimeout(() => fn(v), wait); }; }
const debounced = debounceWith(clock, v => fired.push(v), 300);
debounced("a"); clock.tick(200); debounced("ab"); clock.tick(299);
assert.deepEqual(fired, []);                // not yet: the second call restarted the wait
clock.tick(1);
assert.deepEqual(fired, ["ab"]);            // exactly one call after a full quiet period, and no real waiting

6. Testing async behaviour

  • Await the user-visible outcome (await screen.findByText(...)), never setTimeout sleeps.
  • Test loading, success, empty and error states, not only the happy path.
  • Test race conditions by resolving promises in a deliberate order.
  • Clean up timers, listeners and mocks between tests.
// resolve two requests in the "wrong" order and confirm the UI keeps the latest query
function createSearch(fetcher) {
  let latest = 0, shown = null;
  return {
    async search(q) {
      const id = ++latest;
      const result = await fetcher(q);
      if (id === latest) shown = result;
    },
    get shown() { return shown; },
  };
}
const resolvers = {};
const s = createSearch(q => new Promise(res => { resolvers[q] = res; }));
const p1 = s.search("ab"), p2 = s.search("abc");
resolvers["abc"]("results for abc");
resolvers["ab"]("results for ab");          // the older response arrives last
await Promise.all([p1, p2]);
assert.equal(s.shown, "results for abc");

7. End-to-end tests

Use E2E for a handful of critical journeys against a real browser. To keep them reliable:

  • Auto-waiting locators (Playwright waits for elements to be actionable) instead of fixed sleeps.
  • Isolated data: each test creates its own user and state through an API or seed, not through the UI, and does not depend on test order.
  • Stable selectors: roles and labels, or data-testid for elements with no accessible handle.
  • Network control: mock third-party services; use a real or well-seeded backend for your own.
  • Parallelism with isolated browser contexts.
  • Traces, screenshots and video on failure for fast debugging.
  • Retries for the rare environmental failure, but treat a test that needs retries as a bug to fix.

8. Flaky tests

A flaky test passes and fails with no code change. Causes: timing (sleeps, animations, races), shared state between tests, ordering dependence, real network or time, randomness, environment differences. Remedies: wait on conditions, isolate and reset state, control time and network, seed randomness, run tests in random order locally to expose dependence, quarantine and fix flaky tests quickly, and never normalise "just rerun it".

9. Visual and accessibility testing

  • Visual regression compares screenshots; keep them stable by freezing time and animations, loading fonts, and masking dynamic regions. Good for design systems.
  • Automated accessibility checks (axe) catch a portion of problems; combine them with keyboard and role-based queries in tests. Role queries fail when markup is inaccessible, which is a free check.

10. What to test where: an example

For a product search page:

RiskTest
buildQuery produces wrong URLsunit tests on the pure function
Results list shows loading, empty, error and successcomponent tests with mocked network
Debounce and stale-response handlingunit test with fake timers and controlled promises
Filters survive refresh and sharingintegration test of URL state
Keyboard navigation through resultscomponent test with userEvent.keyboard
A user can search, open a product and add it to the cartone E2E test
The page stays within the performance budgeta Lighthouse CI or bundle-size check

11. Common mistakes

  • Testing implementation details, so refactors break tests.
  • Snapshotting everything, producing noisy, unread diffs. Use small, targeted snapshots or explicit assertions.
  • Over-mocking until the test proves nothing.
  • Sleeping instead of waiting for a condition.
  • Chasing coverage numbers.
  • Too many E2E tests and too few integration tests.
  • Tests that share state or depend on order.
  • Ignoring error and empty states.
  • Not running tests in CI before merge.

12. Practice questions

  1. Describe the testing pyramid and the testing trophy. Where would you invest for a React app?
  2. What does "test behaviour, not implementation" mean? Give an example of each.
  3. How do you query elements in Testing Library, and why that order?
  4. How would you test a debounced search with a stale response?
  5. When do you mock, and what do you avoid mocking?
  6. How do you make E2E tests reliable?
  7. What causes flaky tests, and how do you fix them?
  8. How would you test that a modal is accessible?
Header Logo