Interview prep ยท questions
Testing interview questions
10 questions, from core to senior. Say your answer out loud first, then open the model answer and check it against the points a strong answer covers.
What is the difference between unit, integration, and end-to-end tests, and where do you put most of your effort in a frontend project?
Show the model answer
The levels differ in how much of the system one test runs. A unit test checks one function or hook alone. It is fast and exact, so it fits pure logic: a price calculation or a reducer. An integration test renders several components together with their real state and hooks and replaces only the network. It checks a feature the way a user sees it: fill in the form, press submit, see the message. An end-to-end test runs the whole app in a real browser with a backend. It gives the most confidence, but it is the slowest and the hardest to keep stable. In a frontend most bugs are in how parts work together, not inside one function. So I put most effort into integration tests with React Testing Library. I add unit tests for complex logic and keep a few end-to-end tests in Playwright for the critical journeys.
A strong answer covers
- Defines the levels by how much of the system runs in one test
- Uses unit tests for pure logic because they are fast and exact
- Uses integration tests for features: real components, fake network
- Keeps a few end-to-end tests for critical journeys; slow but high confidence
- Explains why integration tests give the most value in UI code
Likely follow-up
How do you decide if a new feature needs an end-to-end test or if an integration test is enough?
Why is this a good component test, and what does a test of implementation details look like?
test("shows an error when the email is empty", async () => {
const user = userEvent.setup();
render(<SignupForm />);
await user.click(screen.getByRole("button", { name: "Sign up" }));
expect(screen.getByRole("alert")).toHaveTextContent("Email is required");
});
Show the model answer
This test checks behaviour that a user can see, not the way the component is built. It renders the form, finds the button by its role and accessible name, clicks it through user-event, and checks the message that appears. It knows nothing about state variables, hooks, or CSS classes. So I can rewrite the form with another state solution and the test still passes. It fails only when the behaviour is broken. This is the main idea of React Testing Library: a test that works like a user gives more confidence. A test of implementation details checks that a setter was called, reads internal state, or selects by a class name. It breaks on every refactor, and it can pass while the UI is broken. For queries I prefer getByRole, then the label, then visible text, and a test id only as the last option.
A strong answer covers
- Says to test what the user sees and does, not internal state
- Queries by role and accessible name first, then label or text, test id last
- Acts through user-event, like a real user
- Explains the benefit: the test survives a refactor and fails on real breaks
- Gives an example of an implementation-detail test and why it is fragile
Likely follow-up
What is the difference between user-event and fireEvent, and which one do you use?
What do you mock in frontend tests, and how do you know that you mock too much?
Show the model answer
I mock at the boundaries of the system and keep my own code real. The main boundary is the network. With Mock Service Worker I write handlers for HTTP requests, so the components, hooks, and real fetch code all run, and only the server is fake. This is better than mocking fetch or my API module, because the test does not depend on how the request is made. I also control what differs between runs: time with fake timers, random values, and generated ids. The danger of too much mocking is that the test checks the mocks and not the product. If I mock a child component, a hook, or the store, the test can pass while the real parts do not work together. On terms: a stub returns a fixed answer in place of the real function, and a spy records how a function was called.
A strong answer covers
- Mocks at the boundary: the network with Mock Service Worker, time, randomness
- Keeps own components, hooks and store real
- Explains the danger: the test checks the mocks, not the product
- Says why Mock Service Worker is better than mocking fetch or the API module
- Tells a stub (fixed answer) from a spy (records calls)
Likely follow-up
How do you make sure that your mocked responses stay the same as the real API?
This test waits 500 ms for the data to load. What is wrong with that, and how do you test asynchronous UI correctly?
test("shows the user name", async () => {
render(<Profile userId="42" />);
await new Promise((resolve) => setTimeout(resolve, 500));
expect(screen.getByText("Sam Lee")).toBeInTheDocument();
});
Show the model answer
A fixed wait is wrong in two ways. On a slow CI machine the data can take longer and the test fails, and on a fast machine the test wastes time. The correct way is to wait for the result, not for a duration. In React Testing Library, findBy queries return a promise and retry until the element appears or the timeout ends, one second by default. So I write await screen.findByText and then assert. For other conditions I use waitFor with one assertion inside. When the code itself uses timers, such as a debounce, I use fake timers and move time forward in the test. An act warning means that a state update happened when the test was not waiting for it, usually because something was not awaited. The fix is to await the visible change that the update causes, not to wrap code in act manually.
A strong answer covers
- Says a fixed wait is slow and still unstable
- Uses findBy queries or waitFor to wait for the result
- Uses fake timers for debounce and timeouts and moves time forward
- Explains the act warning: a state update the test did not wait for
- Fixes the warning by awaiting the visible change, not by adding act everywhere
Likely follow-up
What is the difference between getBy, queryBy, and findBy queries, and when do you use each one?
Which parts of a product deserve an end-to-end test, and how do you keep these tests stable?
Show the model answer
End-to-end tests are expensive, so I write them only for the few journeys where a failure costs money or blocks users. These are login, checkout and payment, booking, and the main action of the product. For each journey I cover the main successful path and leave edge cases to integration tests. In Playwright I find elements the way a user does, with getByRole and getByLabel, and use a test id only when there is no stable role or text. I do not select by CSS classes or DOM structure. I use the built-in automatic waiting and assertions that retry, never fixed timeouts. Each test creates its own data through the API or a seed script and does not depend on another test, so tests can run in parallel. In CI they run on every pull request against a preview build and save a trace on failure.
A strong answer covers
- Chooses journeys by business risk: login, checkout, booking
- Covers the main successful path; leaves edge cases to lower levels
- Uses role and label locators, not CSS classes or DOM structure
- Gives each test its own data so tests are independent and can run in parallel
- Runs them in CI with traces on failure and no fixed waits
Likely follow-up
How do you handle login, a payment provider, or email confirmation in an end-to-end test?
A bug is reported in production. How do you fix it so that it does not come back?
Show the model answer
I write a failing test before I change the code. First I reproduce the bug and find the smallest case that shows it. Then I write a test at the lowest level that can show the problem, run it, and see it fail for the right reason. This red step proves that the test can detect the bug. Next I make the smallest change that makes the test pass, which is the green step. Then I refactor if needed, and the test protects me. This is the red, green, refactor loop. The order matters. If I fix first and write the test later, I never see the test fail, so I do not know if it would catch the bug. The test stays in the suite as a regression test. I also ask why the existing tests missed the bug, and if similar code has the same problem.
A strong answer covers
- Reproduces the bug and writes a test that fails for the right reason
- Makes the smallest fix that passes the test, then refactors
- Explains why the order matters: a test never seen failing proves little
- Keeps the test in the suite as a regression test
- Asks why the bug was missed and checks similar code
Likely follow-up
The bug depends on timing, and you cannot reproduce it in a test. What do you do?
You join a product that has almost no tests. What do you test first, and how do you decide?
Show the model answer
I start with the code where a failure costs the most, not with the code that is easiest to test. I rank features by risk with four questions. How much revenue do we lose if it breaks, for example payment or sign up? Can a failure harm users, for example wrong or lost data? How often does the code run and change? And how complex is the logic, such as permissions or flows with many branches? I take the facts from analytics, the bug tracker, and the git history. My first step is a few end-to-end tests for the critical journeys, because they protect a lot and need little knowledge of the code. Then I add integration tests for high-risk features and unit tests for complex logic. After that, every bug fix gets a regression test, and CI blocks a merge when tests fail.
A strong answer covers
- Ranks by risk, not by what is easiest to test
- Uses four factors: revenue, harm to users, how often it runs or changes, complexity
- Takes the facts from analytics, bug history and git history
- Starts with a few end-to-end tests for the critical journeys
- Sets rules for new work: a regression test per bug, and CI blocks failing merges
Likely follow-up
How do you convince a product manager to give time for tests when there is pressure to ship features?
What makes a test flaky, and what do you do with a flaky test until it is fixed?
Show the model answer
A flaky test passes and fails with the same code. The cause is usually something that the test does not control. Timing: fixed waits, animations, or an assertion that runs before the UI is ready. Shared state: the same database rows, local storage, or mocks that are not reset. Network: calls to a real external service or a slow backend. Test order: a test passes only when another test ran before it. To fix them, I wait for a condition and not for a time. I give each test its own data and reset state between tests. I mock external services and run tests in random order. Until a flaky test is fixed, I quarantine it. I move it out of the main suite with a ticket and an owner. Otherwise people start to ignore failed builds. Automatic retries help for a short time, but they can hide real bugs.
A strong answer covers
- Defines flaky: different results with the same code
- Names the causes: timing, shared state, network, test order
- Gives a fix for each: wait for conditions, isolate data, mock, random order
- Quarantines the test with a ticket and an owner
- Says retries are a short-term help and can hide real bugs
Likely follow-up
A test fails only in CI and never on your machine. How do you investigate?
What does a code coverage number tell you, and how do you use it in a team?
Show the model answer
Coverage tells me which lines and branches ran during the tests. It does not tell me that the results were checked. A test that calls a function and asserts nothing still covers it. Low coverage is a clear signal: code that no test runs is not protected. High coverage is a weak signal, because tests can still miss wrong results and missing features. When the number becomes a target, people write tests that only run code, and the number loses its meaning. I use coverage to find gaps, not as a goal. The report shows me important code without tests, such as error branches in payment logic. In CI I prefer a check on the changed lines of a pull request, or a moderate threshold that must not go down. I do not ask for 100 percent. To judge the quality of assertions, mutation testing is a better tool.
A strong answer covers
- Says coverage shows which code ran, not that results were checked
- Says low coverage is a clear warning and high coverage proves little
- Explains what happens when the number becomes a target
- Uses the report to find risky code and branches without tests
- Prefers a check on changed code or a moderate threshold over 100 percent
Likely follow-up
A team has 90 percent coverage and still ships regressions. What do you look at?
You must refactor or migrate a legacy module that has no tests. How do you do it safely?
Show the model answer
Before I change anything, I record the current behaviour with characterization tests. Such a test does not describe what the code should do. It records what the code does today. I run the code with realistic inputs and put the real output into the assertion, even when it looks wrong. Other code or users can depend on that behaviour, so fixing it is a separate decision and a separate change. For UI code I write these tests from the outside, with React Testing Library or a few Playwright flows. Then they do not depend on the internal structure that I will change. Then I refactor in small steps, run the tests after each step, and commit often, so a failure points to one small change. For a migration I move one part at a time and keep the old and new versions working together, often behind a feature flag.
A strong answer covers
- Writes characterization tests that record what the code does today
- Tests from the outside so the tests survive the internal change
- Keeps bug fixes separate from the refactor
- Works in small steps and runs the tests after each one
- Migrates one part at a time, with old and new side by side or behind a flag
Likely follow-up
During the refactor you find behaviour that is clearly a bug. Do you fix it in the same change?