"We have 94% coverage," he said, with the tone of someone laying down a winning card. The interviewer asked what the tests would catch if he deleted the body of the discount calculation and returned zero. He thought about it. About forty of them would fail. The interviewer asked what would happen if he changed the discount from 10% to 12%. Nothing. Nothing at all would fail, because no test asserted the number — they asserted that a number came back.
Most unit testing interview questions are aimed at exactly that gap: whether you write tests that verify behaviour or tests that verify that code ran. This guide covers the six areas that recur, with the answers that mark you as someone who has maintained a test suite rather than only written one.
What is a unit, actually
The weak answer is "one class" or "one function". The stronger answer, and the one that opens a better conversation: a unit is one behaviour, tested in isolation from anything slow or non-deterministic.
That definition matters because it changes what you mock. If a behaviour spans three small collaborating classes with no I/O, testing them together is still a unit test by any useful standard, and it produces a suite that survives refactoring. Testing each class in isolation with mocks between them produces a suite that breaks every time you move a method, which is how teams end up deleting their tests.
Then define the boundary: a unit test does not touch the network, the filesystem, a real database, the clock, or randomness. Everything else is an integration test, and that is fine — the pyramid needs both.
Test doubles: mock, stub, fake, spy
Asked constantly, usually answered as "a mock is a fake object", which is not enough. The precise version:
- Stub — returns canned data. Used to control the input to the code under test.
- Mock — has expectations about how it is called, and the test fails if those calls do not happen. Used to verify an interaction.
- Fake — a working lightweight implementation, such as an in-memory repository. Often the best option and the most under-used.
- Spy — a real object that also records how it was used.
- Dummy — passed only to satisfy a signature, never used.
The follow-up that separates candidates: when should you not use a mock? When you are mocking a type you do not own, because your mock encodes an assumption about a third-party API that will drift silently. Wrap it in your own interface and mock that instead. Also: mocking value objects, or mocking so much that the test asserts your implementation rather than the behaviour.
The senior sentence: "over-mocking couples the test to the implementation, so refactoring breaks tests without any behaviour changing — which trains people to stop trusting the suite."
What to test, and what not to
- Test behaviour, not implementation. Assert what comes out, not which private method ran.
- Test boundaries. Zero, one, many, maximum, empty, null, and the value just past the limit. Boundaries are where the bugs are.
- Test error paths. The exception, the timeout, the invalid input — these are usually where production breaks and usually the least tested.
- Do not test private methods directly. If a private method is complex enough to need its own test, it probably wants to be its own class with a public interface.
- Do not test the framework or the language. A test asserting that your ORM saves a row is testing the ORM.
- Do not test trivial getters and setters. Coverage tools reward it; nobody else does.
- One logical assertion per test. Not literally one assert statement, but one reason for the test to fail, so a red test names its own cause.
Naming matters more than candidates expect. test_discount_is_zero_when_cart_is_empty tells you what broke; test_discount_2 does not. Interviewers do notice.
# the test that would have caught the opening story: assert the value, not the shape
def test_ten_percent_discount_applied_above_threshold():
cart = Cart(items=[Item(price=1000), Item(price=500)]) # 1500 total
assert calculate_discount(cart) == 150 # the number, not "is not None"
def test_no_discount_below_threshold():
assert calculate_discount(Cart(items=[Item(price=100)])) == 0
def test_discount_capped_at_maximum():
huge = Cart(items=[Item(price=1_000_000)])
assert calculate_discount(huge) == MAX_DISCOUNT # boundary, not happy path
Coverage
"What coverage should you aim for?" The answer that scores has two halves.
Coverage is a good diagnostic — a file at 0% is worth a look, and a coverage drop on a pull request is a fair thing to flag. It is a poor target, because it measures execution rather than assertion, and a suite can execute every line while asserting almost nothing. That is exactly the situation in the opening story.
Also distinguish line coverage from branch coverage, since line coverage can be complete while half the conditional branches are untested. And say what you would actually use instead: mutation testing, which changes the code and checks whether any test notices. Naming mutation testing unprompted is a strong signal, and it directly answers "would a test have caught this change?"
TDD
Expect "do you practise TDD?" and answer honestly rather than dogmatically.
Describe the loop: red, green, refactor — write a failing test, write the minimum to pass, then improve the design with the test protecting you. Then be specific about where it helps and where it does not.
It genuinely helps for well-understood logic with clear inputs and outputs — parsers, calculations, business rules, bug fixes where you reproduce the bug as a test first. It helps less when you are exploring an unfamiliar API or building a UI whose shape you are still discovering, where writing tests first mostly produces tests you throw away.
The honest, credible position: "I write the test first for logic and for bug fixes always, and I am looser when I am still figuring out the design." Interviewers respond much better to that than to either evangelism or dismissal.
Testing code that is hard to test
This is the round's best question, because untestable code is the normal case.
- Dependencies are the problem. Code that constructs its own database connection or calls a static clock cannot be isolated. The fix is a seam: inject the dependency, or extract the awkward call behind an interface.
- Time. Never call
now()inside the logic. Inject a clock, so a test can pin the date. This single change makes an enormous amount of code testable. - Randomness. Inject the generator or the seed.
- Legacy code you cannot restructure. Michael Feathers' approach — find the smallest seam, write a characterisation test that captures current behaviour even if that behaviour is wrong, then refactor with that as your safety net.
- Global state and singletons. The reason two tests pass alone and fail together. Say this out loud; it is the most common flakiness cause in unit suites.
- Private and static methods. In most languages the answer is to change the design rather than reach for a tool that lets you test them anyway.
Practical questions you should expect
- "What makes a good unit test?" — fast, isolated, deterministic, one reason to fail, and readable enough to serve as documentation.
- "Why is a test flaky and how do you fix it?" — shared state, ordering dependence, real time, real network, or concurrency. Quarantine, then fix the cause rather than adding retries. Our SDET interview questions guide covers this at suite scale.
- "How do you test asynchronous code?" — control the scheduler or clock, await deterministically, and never sleep.
- "Should tests run in CI on every commit?" — unit tests yes, because they are fast; slower suites move later in the pipeline. Our CI/CD interview questions guide covers the staging.
- "How do you test a function with no return value?" — verify the observable effect, which is the legitimate use of a mock or spy.
Where each prep option actually helps
- Working Effectively with Legacy Code by Michael Feathers — the source of the seams vocabulary, and the single best preparation for the hard-to-test question.
- Your own codebase — find a class you could not test and work out why. That story is worth more in an interview than any definition.
- The documentation for your framework — JUnit, pytest, Jest — enough to write a parameterised test and a fixture from memory.
- A mutation testing tool — running one once, on real code, permanently changes how you talk about coverage.
- ChatGPT — genuinely good at generating test cases for a function you paste. It will not tell you that your assertions are checking shape rather than value.
- Greenroom — the spoken layer. Ari, the AI interviewer runs these rounds out loud and asks why, which is where memorised definitions stop. Honest tradeoff: Ari will not run your suite, so write real tests too.
Frequently asked questions
What is the difference between a mock and a stub?
A stub returns canned data and exists to control the input to the code under test, while a mock carries expectations about how it is called and fails the test if those interactions do not happen. A fake is a working lightweight implementation such as an in-memory repository, a spy is a real object that also records its usage, and a dummy is passed only to satisfy a signature. The important follow-up is that you should not mock a type you do not own, because your mock encodes an assumption about a third-party API that can drift silently.
What should you not unit test?
Private methods directly, because a private method complex enough to need its own test usually wants to be its own class with a public interface. Also the framework or language itself — a test asserting that your ORM saves a row is testing the ORM — and trivial getters and setters, which coverage tools reward and nobody else does. Knowing what deliberately not to test is one of the clearest seniority signals in this round.
What is a good unit test coverage percentage?
Coverage is a useful diagnostic and a poor target. It measures whether lines executed, not whether anything was asserted, so a suite can reach very high coverage while catching almost no real regressions. Use it to spot files at zero and to flag drops on a pull request, distinguish line coverage from branch coverage, and if you want to know whether tests would actually catch a change, use mutation testing instead of raising a coverage threshold.
How do you test code that is hard to test?
The problem is almost always dependencies, so create a seam: inject the database, the clock or the random generator rather than constructing them inside the logic. Never call now() inside business logic, because injecting a clock makes an enormous amount of code testable at once. For legacy code you cannot restructure, write a characterisation test that captures current behaviour even if that behaviour is wrong, then refactor with that test as a safety net.
Do you have to practise TDD to pass a testing interview?
No, and dogmatism in either direction reads badly. Describe the red-green-refactor loop accurately, then be specific: it genuinely helps for well-understood logic with clear inputs and outputs such as parsers, calculations, business rules and bug fixes where you reproduce the bug as a test first, and it helps less when exploring an unfamiliar API or a UI whose shape is still being discovered. A credible position is that you always test-first for logic and bug fixes and are looser while designing.
Why do two tests pass alone but fail together?
Almost always shared state — a global, a singleton, a static cache, a shared database row or a fixture that one test mutates and another depends on. It can also be test ordering dependence, real time or timezone assumptions, or leaked concurrency from a previous test. The fix is making every test set up and tear down its own state so it can run alone and in any order, rather than adding retries that hide the problem.