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
| Level | What it checks | Speed | Typical tools |
|---|---|---|---|
| Static analysis | types, lint rules, formatting | instant | TypeScript, ESLint, Prettier |
| Unit | one function or module in isolation | very fast | Vitest, Jest, Node test runner |
| Component / integration | a component or feature with real children, in a simulated DOM | fast | Testing Library with Vitest or Jest |
| End-to-end (E2E) | the real app in a real browser against a (test) backend | slow | Playwright, Cypress |
| Visual regression | screenshots against baselines | medium | Playwright, Chromatic, Percy |
| Accessibility | rules and flows | fast to medium | axe, jest-axe, Playwright with axe |
| Performance | budgets, Web Vitals | varies | Lighthouse 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
- 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.
- Arrange, act, assert. Set up, perform one action, check the outcome.
- One reason to fail. Name tests by behaviour: "shows an error when the email is invalid".
- Independent and deterministic. No shared mutable state between tests; control time, randomness and network.
- Fast feedback. A suite people avoid running protects nothing.
- 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) overfireEvent. - 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
| Double | Purpose |
|---|---|
| Stub | returns canned data |
| Fake | a lightweight working implementation (an in-memory store) |
| Spy | wraps the real function and records calls |
| Mock | a 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(...)), neversetTimeoutsleeps. - 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-testidfor 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:
| Risk | Test |
|---|---|
buildQuery produces wrong URLs | unit tests on the pure function |
| Results list shows loading, empty, error and success | component tests with mocked network |
| Debounce and stale-response handling | unit test with fake timers and controlled promises |
| Filters survive refresh and sharing | integration test of URL state |
| Keyboard navigation through results | component test with userEvent.keyboard |
| A user can search, open a product and add it to the cart | one E2E test |
| The page stays within the performance budget | a 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
- Describe the testing pyramid and the testing trophy. Where would you invest for a React app?
- What does "test behaviour, not implementation" mean? Give an example of each.
- How do you query elements in Testing Library, and why that order?
- How would you test a debounced search with a stale response?
- When do you mock, and what do you avoid mocking?
- How do you make E2E tests reliable?
- What causes flaky tests, and how do you fix them?
- How would you test that a modal is accessible?