Testing interview questions
How to choose and write tests: the pyramid and the trophy, unit versus integration versus end-to-end, mocking and test doubles, async code, flaky tests, contract tests, TDD, coverage, Testing Library, Playwright, and property-based testing.
Answer out loud or on paper before revealing. If your answer misses a key point, revisit the linked guide or explainer.
Nothing is due right now. Every question in this set is scheduled for a later review.
Compare the test pyramid with the testing trophy. Which would you follow?
Reveal model answer
The pyramid says to write many fast, isolated unit tests, fewer integration or service tests, and only a few slow end-to-end tests, because cost and brittleness grow as you go up. The trophy, popularized by Kent C. Dodds for JavaScript apps, adds static analysis (types and lint) at the base and makes integration tests the widest layer, on the argument that they buy the most confidence per unit of effort: "Write tests. Not too many. Mostly integration." They agree more than they disagree: both want few end-to-end tests and fast feedback, and they differ mainly in what counts as a unit and how much to mock. Choose the mix per codebase: logic-heavy domain code suits unit tests, while UI and glue code suits integration tests that render real components against a faked network.
WHAT A STRONG ANSWER COVERS
- The pyramid has cheap, fast tests at the bottom and expensive, broad ones at the top.
- The trophy adds static checks and favors integration tests.
- Both keep end-to-end tests few and focused on critical journeys.
- The real disagreement is over test granularity and mocking.
- The goal is confidence per unit of cost and runtime, not a particular shape.
RED FLAGS
- Treating either shape as a law with fixed ratios.
- An "ice cream cone": mostly manual and end-to-end tests with few fast ones.
LIKELY FOLLOW-UPS
- What counts as an integration test in a React app, and what in a backend service?
How did your answer compare?
SOURCES
You're adding a checkout discount feature (pricing rules, an API endpoint, and a form). Which tests do you write at which level?
Reveal model answer
Put the pricing rules in a pure function and cover them with fast unit tests (or property-based tests) for every rule, boundary, and rounding case, because that's where combinations explode and unit tests are cheapest. Cover the endpoint with integration tests that run against a real database in a container, checking validation, authorization, persistence, and the error format, without mocking your own repositories. Cover the form with component tests through Testing Library against a faked network (for example MSW), checking what the user sees for valid, invalid, and failing requests. Add one or two end-to-end tests for the happy path through the real stack, because end-to-end tests are slow and flaky, so each one should protect a journey that matters to the business.
WHAT A STRONG ANSWER COVERS
- Push combinatorial logic down to pure functions and unit tests.
- Integration tests at real boundaries such as the database and HTTP.
- Component tests check user-visible behavior with a faked network.
- Only a few end-to-end tests, for critical journeys.
- Each level answers a different question; avoid duplicating the same assertion at every level.
RED FLAGS
- Testing every pricing permutation through the browser.
- Mocking the database in the endpoint test, so SQL and constraints are never exercised.
- Having only unit tests, with every collaborator mocked.
LIKELY FOLLOW-UPS
- The end-to-end test fails once a week. What do you do with it?
How did your answer compare?
SOURCES
A test does `vi.mock("./priceRepository")`, `vi.mock("./discounts")`, and `vi.mock("axios")`, then asserts that `calculateTotal` called each mock once. What's wrong, and what should you mock instead?
Reveal model answer
The test mocks every collaborator and checks the calls, so it only proves the code is wired the way it's wired today: refactor the internals and it breaks, introduce a real bug in `discounts` and it still passes. Mock at the edges of the system you own, the slow or nondeterministic things such as the network, the clock, randomness, and third-party services, and let your own modules run for real. Prefer fakes over call-checking mocks: an in-memory repository, or MSW intercepting HTTP at the network layer instead of mocking the `axios` module. Follow "don't mock what you don't own": wrap a third-party SDK in a thin adapter you control and fake the adapter. Then assert on outcomes, such as the returned total, instead of on which calls happened.
WHAT A STRONG ANSWER COVERS
- Over-mocking couples tests to the implementation and hides integration bugs.
- Mock boundaries: network, time, randomness, third parties.
- Fakes and network-level interception over module mocks.
- Wrap third-party APIs in adapters you own.
- Check outcomes (state) over interactions, unless the interaction is the behavior, such as "an email was sent".
RED FLAGS
- Saying "unit test" means every import must be mocked.
- Asserting `toHaveBeenCalledTimes` on internal helpers.
- Mocking the module under test.
LIKELY FOLLOW-UPS
- When is checking an interaction the right assertion?
How did your answer compare?
SOURCES
Define dummy, stub, spy, mock, and fake.
Reveal model answer
These come from Gerard Meszaros, and "test double" is the umbrella term. A dummy is passed in only to fill a parameter and is never used. A stub returns canned answers to the calls a test makes. A spy is a stub that also records how it was called, so the test can check it afterwards. A mock is set up in advance with expectations about the calls it should receive and fails verification if they don't happen. A fake has a real but simplified implementation that isn't fit for production, such as an in-memory database. Stubs and fakes support checking state (what came out), and mocks and spies support checking behavior (what was called).
WHAT A STRONG ANSWER COVERS
- Correct definitions for each of the five.
- "Test double" as the umbrella term.
- State verification versus behavior verification.
- Fakes are often the most robust choice for repositories and services.
RED FLAGS
- Calling every double a mock and not seeing why the difference matters.
LIKELY FOLLOW-UPS
- How do you keep a fake in sync with the real implementation?
How did your answer compare?
SOURCES
Why does `it("loads user", () => { fetchUser(1).then((u) => expect(u.name).toBe("Ada")); })` pass even when `fetchUser` returns the wrong name?
Reveal model answer
The test function neither returns nor awaits the promise, so the runner sees it finish synchronously and marks it passed before the `.then` runs. If the assertion fails later, it either surfaces as an unhandled rejection or is lost entirely. Make the test `async` and `await` the call, or return the promise, and use `await expect(promise).rejects.toThrow()` for failures. For code driven by timers, use fake timers and advance them instead of waiting for real time. In UI tests, wait for the result the user sees (`findByRole`, `waitFor`) rather than for a fixed delay. A lint rule such as `no-floating-promises` catches this kind of bug before it reaches review.
WHAT A STRONG ANSWER COVERS
- The runner only waits for a promise that is returned or awaited.
- `async`/`await`, plus `.resolves` and `.rejects` matchers.
- Fake timers for time-based code.
- Wait for an observable condition, never for a fixed sleep.
- Lint for floating promises.
RED FLAGS
- Adding `setTimeout(done, 1000)` as the fix.
- Wrapping the assertion in `try/catch` and swallowing the error.
LIKELY FOLLOW-UPS
- How do you test a debounced search input?
How did your answer compare?
SOURCES
Your CI suite fails about one run in ten, on different tests each time. How do you find and fix the causes?
Reveal model answer
First stop the damage: track failures per test across runs, and quarantine the known-flaky tests into a non-blocking suite with an owner and a deadline, because a suite people learn to rerun stops catching real bugs. Then fix the causes, which usually fall into a few groups. Shared state and test order: a database, globals, or singletons leaking between tests, so give each test its own data or roll back a transaction. Async timing: fixed sleeps and assertions that don't wait, so wait for conditions instead. Time and randomness: a real clock, time zones, or unseeded random values, so inject or freeze them. Real external services: replace them with fakes and cover the real ones with contract tests. Resource limits and leaks under parallel runs. Reproduce by running the failing test many times, in random order and in parallel, and treat retries as a diagnostic signal rather than the fix.
WHAT A STRONG ANSWER COVERS
- Measure flakiness and quarantine it, with an owner and a deadline.
- Isolation: no shared mutable state, and fresh data per test.
- Condition-based waits, not sleeps.
- Control the clock, time zone, and random seed.
- Fakes for remote services, plus contract tests.
- Retries can hide real race conditions in the product.
RED FLAGS
- Turning on automatic retries everywhere and moving on.
- Deleting flaky tests without understanding them.
- Increasing timeouts as the only fix.
LIKELY FOLLOW-UPS
- When is a flaky test really finding a bug in the product?
How did your answer compare?
SOURCES
What are consumer-driven contract tests, and when would you choose them over end-to-end tests across services?
Reveal model answer
A contract test checks an integration point by testing each side in isolation against a shared contract. In consumer-driven contracts (Pact is the common tool), the consumer's tests run against a mock provider and record the concrete requests and responses they rely on; the provider then replays that contract against its real implementation in its own CI. This catches breaking changes such as a renamed field or a new required parameter before deploy, without standing up every service together, and it only constrains the parts of the API that consumers actually use. Choose it when several teams or services deploy independently and shared end-to-end environments are slow and flaky. It doesn't replace a few end-to-end smoke tests, and it checks the shape of messages, not business correctness.
WHAT A STRONG ANSWER COVERS
- Each side is tested in isolation against a shared contract.
- The consumer records expectations and the provider verifies them in its own pipeline.
- Catches breaking API changes before deploy.
- Contracts are examples, unlike a schema (OpenAPI) describing every possible state.
- Fits independently deployed services; still keep a few end-to-end smoke tests.
RED FLAGS
- Saying contract tests are just validating against an OpenAPI schema.
- Writing contracts on the provider side that list every field it returns.
- Treating contract tests as functional tests of the provider's logic.
LIKELY FOLLOW-UPS
- How do you gate deploys on contract verification across several environments?
- How do contract tests handle provider state, such as "user 42 exists"?
How did your answer compare?
SOURCES
What is TDD, and when is it most and least useful?
Reveal model answer
Test-driven development is a short loop: write a failing test for the next small behavior (red), write just enough code to pass it (green), then refactor both the code and the tests while they stay green. The value is a design pressure toward small, testable interfaces, plus a regression suite and a refactoring safety net that you get as a by-product. The step people most often skip is refactoring, which leaves well-tested but messy code. TDD works best for well-understood logic with clear inputs and outputs, such as parsers, pricing, and bug fixes, where you write the failing test that reproduces the bug first. It works less well during exploratory spikes or UI layout work, where you don't yet know what to assert; spike first, then rebuild with tests.
WHAT A STRONG ANSWER COVERS
- Red, green, refactor, in small steps.
- Refactoring is part of the loop, not optional.
- Better interface design is the main benefit; tests are a by-product.
- Reproduce a bug with a failing test before fixing it.
- Pragmatic about spikes and exploratory work.
RED FLAGS
- Writing all the tests up front for a whole feature.
- Dogmatism: TDD always or never.
LIKELY FOLLOW-UPS
- How would you start TDD on legacy code with no tests?
How did your answer compare?
SOURCES
Management wants 100% coverage enforced in CI. What do you tell them?
Reveal model answer
Coverage measures which code ran during tests, not whether anything was checked: a test with no assertions can cover a whole module. It's good at finding untested areas, and very low coverage is a real warning sign, but once a number becomes the target people reach it with weak tests and spend effort on trivial code. A better policy is to review coverage of the changed lines in pull requests, look for untested risky code (error paths, branching logic), and track outcomes: whether bugs escape to production and whether people are afraid to change code. Branch coverage says more than line coverage, and mutation testing (for example Stryker) checks whether the tests actually fail when the code is wrong.
WHAT A STRONG ANSWER COVERS
- Coverage shows execution, not verification.
- Useful for finding gaps, weak as a quality score.
- Targets get gamed (Goodhart's law).
- Coverage on changed lines, branch coverage, and mutation testing.
- Better signals: escaped defects and confidence to change code.
RED FLAGS
- Saying 100% coverage means no bugs.
- Rejecting coverage data entirely.
LIKELY FOLLOW-UPS
- How does mutation testing work, and what does it cost?
How did your answer compare?
SOURCES
A component test does `container.querySelector(".btn-primary").click()` and then `expect(wrapper.state("isOpen")).toBe(true)`. How would you rewrite it, and why?
Reveal model answer
It reaches for implementation details, a CSS class and internal state, so it fails when someone renames state or restyles the button (a false negative) and still passes when the button's handler is wired wrong (a false positive). With Testing Library you find elements the way users and assistive technology do: `screen.getByRole("button", { name: /open menu/i })`. You interact through `userEvent`, which simulates real event sequences, and you assert on what changed for the user, such as `expect(await screen.findByRole("menu")).toBeVisible()`. Use `queryBy` to assert something is absent, and `findBy` or `waitFor` for async updates. Reach for `getByTestId` only when no role or label fits. As a bonus, queries by role fail when the markup isn't accessible.
WHAT A STRONG ANSWER COVERS
- Test behavior through the DOM, not component internals.
- Query priority: role and label first, test IDs last.
- `userEvent` over firing raw events.
- `getBy`, `queryBy`, and `findBy` and when to use each.
- Role queries double as an accessibility check.
RED FLAGS
- Snapshot tests as the main way of checking behavior.
- Wrapping everything in `act()` by hand to silence warnings.
- Shallow rendering, so child components never run.
LIKELY FOLLOW-UPS
- How would you test a component that fetches data on mount?
How did your answer compare?
SOURCES
A Playwright test does `await page.click("#submit"); await page.waitForTimeout(2000); expect(await page.isVisible(".toast")).toBe(true);` and is flaky in CI. What's wrong, and what are Playwright best practices?
Reveal model answer
The fixed two-second wait is either too long locally or too short on a slow CI runner. `isVisible()` checks once, with no retry, and the CSS selectors break when the markup changes. Use user-facing locators and web-first assertions, which wait and retry automatically: `await page.getByRole("button", { name: "Submit" }).click(); await expect(page.getByRole("status")).toHaveText(/saved/i);`. More broadly: keep each test isolated, with its own browser context and data; reuse a saved login state from a setup project instead of logging in through the UI every time; mock third-party services you don't control with network routing; collect traces on the first retry to debug CI failures; run in parallel and shard across machines; and lint for missing `await`s.
WHAT A STRONG ANSWER COVERS
- No fixed waits; web-first assertions retry.
- Role, label, and text locators instead of CSS or XPath.
- Isolated tests and a reusable authentication state.
- Mock external dependencies at the network layer.
- Traces for CI debugging; parallel runs and sharding.
- Lint for floating promises.
RED FLAGS
- Raising timeouts or adding retries as the fix.
- Tests that depend on running in a particular order.
- Testing a third-party site you don't control.
LIKELY FOLLOW-UPS
- How do you seed and clean up backend data for parallel end-to-end tests?
How did your answer compare?
SOURCES
What is property-based testing, and where would you use it?
Reveal model answer
Instead of hand-picking example inputs, you state a property that must hold for every input, and a library such as fast-check (JavaScript) or Hypothesis (Python) generates hundreds of random inputs to try to break it. When it finds a failure, it shrinks the input to a minimal counterexample, and because generation is seeded, you can replay the failing seed. Good properties are round trips (`parse(serialize(x))` equals `x`), invariants (sorting keeps length and gives an ordered result), agreement with a simpler reference implementation, and idempotence. It works well for parsers, serializers, money and date arithmetic, and state machines, where edge cases are hard to list by hand. Keep a few example-based tests for readability, and turn every counterexample found into a regression test.
WHAT A STRONG ANSWER COVERS
- Properties over examples; inputs are generated.
- Shrinking to a minimal counterexample.
- Seeded, reproducible runs.
- Common property patterns: round trip, invariant, reference implementation, idempotence.
- Best for code with large input spaces.
RED FLAGS
- Restating the implementation as the property.
- Generators so narrow they never produce edge cases.
LIKELY FOLLOW-UPS
- How would you property-test a shopping-cart state machine?
How did your answer compare?
SOURCES