Testing interview questions: what your tests say about your judgment
Nobody senior gets asked to define a unit test. You get asked which tests you'd write, which ones you'd delete, and why, and every one of those is a question about cost.
A testing round is one of the few places an interviewer can watch you make a trade-off in real time. There's no correct number of tests, no framework that's right, and no answer that holds for every codebase. So the questions get built to find out whether you've paid for your opinions or collected them.
The six below come up constantly in senior screens, and they share a spine. A test costs something to write, something to run on every commit for years, and something to maintain when the code moves underneath it. The skill being scored is knowing what you bought. If you'd rather work through the topic than read about it, the testing track covers the same ground one idea at a time.
Question 1: what do you mock?
If I got one question, this would be it. It sounds like a tooling question and it's a question about who owns the contract you're testing against.
How would you test a checkout that calls a payment provider?
Junior answer
Mock the payment client and assert it got called with the right amount. That keeps the test fast and off the network.
Senior answer
I'd put a thin interface of my own in front of their SDK and test against a fake implementation of that, so the test is coupled to my contract rather than to theirs. A mock of a vendor SDK is a written record of what I believed their API did on the day I wrote it, and it keeps passing happily after they change it, which is the exact failure I want the test to catch. Then one contract test against their sandbox, running on a schedule instead of in every pull request, so drift shows up without blocking anybody's merge.
The follow-up is nearly always what happens when the provider renames a field, and the mocked version has no answer to give.
Don't mock what you don't own is worth saying out loud, as long as you can defend it. The reason it holds is that a mock is a claim, and a claim about someone else's system is one you have no way to keep true. A fake you wrote of an interface you wrote is a claim about your own code, and if it drifts, the compiler or the caller tells you.
There's a second thing to notice about heavy mocking, and volunteering it reads well. If the arrange section of a test needs five mocks before it can call anything, the test has just told you the unit under it has five collaborators. That's a design finding, not a testing one, and the fix usually isn't a better mocking library.
Question 2: this test fails about one run in twenty
Flakiness gets treated as weather, something that happens to a suite. It isn't. It's information about the code, and in almost every case it's one of four things.
- Shared state between tests. A module-level cache, a row left in a table, a global mutated in a helper. The tell is that it passes alone and fails in the suite, or fails only when the runner shuffles order.
- Real time. A test that builds a date, crosses midnight or a month boundary, or asserts on a duration. Clocks are an input, so inject one.
- Ordering dependence. Two tests that both pass, in one order. Running them in a random order on purpose is how you find these, and it's a good thing to say you'd turn on.
- Sleeping instead of waiting.
await sleep(100)is a bet that a machine you don't control finishes in under 100ms, and CI is always slower than your laptop.
The part that separates answers is what you'd do about it as a policy rather than as a debugging session. A flaky test with an automatic retry has stopped being a test. It's a coin flip that occasionally blocks a deploy, and worse, it trains the team to rerun a red build without reading it, which is the habit that lets a real failure through. Quarantine it with an owner and a date, and delete it when the date passes. A test parked in quarantine forever is a line in a report that nobody is allowed to believe.
Question 3: what coverage should we aim for?
This is a trap only if you think it wants a number. Coverage measures which lines executed. It says nothing at all about whether anything was checked.
// Full coverage of createUser. Zero assertions.
test("createUser", async () => {
await createUser({ email: "a@b.com" });
});The number is useful in one direction and one direction only. A file sitting at 3% is telling you something real. A repo at 92% is telling you almost nothing, because you can't see from the number whether the covered lines are the ones where a mistake is expensive. Say where you'd want it high anyway: money, permission checks, anything that migrates data, anything you can't easily reverse in production.
As a gate in CI it does what every target does to the thing it measures. It's cheap to move a percentage without writing a single test that would ever fail, so a hard threshold teaches people to test the getters. If a gate is non-negotiable, a ratchet is the version that survives contact with a team: the number isn't allowed to go down. What you actually want out of the report is the list of untested files, because the payments module is at 20% is a conversation and 84.2% isn't.
The live task: write tests for this function
Most rounds have one keyboard moment, and in a testing round it's usually a small function handed over with write some tests for this. What's being scored isn't the syntax of your assertion library. It's which cases you pick, and in what order you get there.
- Say what the function is supposed to do, in one sentence, before writing anything. If you can't, that's the finding, and asking beats guessing in front of someone who wrote the spec.
- Name the cases out loud before you write them: the ordinary one, the boundary, and the way it's meant to fail. Three chosen cases beat nine variations of the happy path, and the choosing is the part being watched.
- Write the first one so its failure message would tell you what broke. A test called works that asserts a deep equality is useless at 3am, and naming tests after the behaviour is free.
- Then go after the edges, and say which ones you're deliberately leaving out. Knowing what you didn't cover is a senior signal. Implying you covered everything is not.
Narrating the choice is what carries this, because the interviewer can read your code for themselves but can't read why you stopped at three cases. That's the same reason the follow-up is the interview, and it's the hardest part to rehearse alone.
Question 4: this test broke when I refactored, but nothing is broken
A test that knows how the code works on the inside is a second copy of the code, and now you maintain both. The useful check fits in one sentence: a test should fail when the behaviour changes and stay green when only the structure does.
You renamed a private method and forty tests went red. What went wrong?
Junior answer
The tests were tightly coupled to the implementation, so they need updating to match the new structure.
Senior answer
Those tests were written against the inside of the unit, so they aren't really tests, they're a snapshot of one design decision. Forty failures with no change a user could see means the suite is reporting on my refactor rather than on my product, and it's now an argument against refactoring, which is the opposite of what I bought it for. I'd move the assertions to the public entry point of the module and let the private parts move freely, and I'd expect to come out the other side with a lot fewer than forty tests.
The follow-up is whether that means you never test private functions, and the answer that lands is through their caller, unless a piece is genuinely intricate and pure, in which case pull it out and make it a real unit with a name.
Both versions of that code work today, which is what makes it a good interview question and a good example of the gap between a correct answer and a senior one. Nothing is failing. The difference only shows up six months later, in whether the suite makes changing the code cheaper or more expensive.
Question 5: how do you test the code that touches the database?
Backend rounds get here quickly, and the honest answer is unfashionable in a way interviewers tend to like.
Would you mock the database in those tests?
Junior answer
Yes, mock the ORM or the query layer, so the tests stay fast and CI doesn't need a running server.
Senior answer
No, I'd run a real one in a container. A mocked query layer only ever tests my understanding of the library, and my understanding of the library is precisely what's wrong on the day this breaks. A unique constraint, a cascade, what a transaction does on rollback, how the driver hands back a null: none of that exists in a mock, and it's most of what a data-access bug actually is. The cost is real and I'd say so. It's slower and it needs infrastructure in CI, so I'd wrap each test in a transaction and roll it back rather than truncating between tests, and I'd keep the number of these deliberately small and aimed at the queries that carry logic.
Expect a follow-up about speed, and expect it to go better if your answer has a number in it instead of a promise that it's fine.
Worth knowing that this question is often really a database question with a testing wrapper on it, so the interviewer may follow it into isolation levels or index behaviour. That's covered in the database round, and it's the sort of pivot worth being ready for rather than surprised by.
Common questions
Test pyramid or testing trophy?
Neither one is a rule, and quoting a diagram is a weaker answer than reasoning about the codebase in front of you. The shape should follow from where your failures actually come from. If most of your incidents are wiring, config and integration, a thousand more unit tests wouldn't have caught a single one, and the effort belongs higher up. Say which shape you'd pick for the system they just described, and why.
What would you do with a codebase that has no tests?
Don't answer with add tests. Answer with an order. Put one test around the outside of the next thing you have to change, so the edit has a safety net. Write a test for every bug before fixing it, since those cases already proved they matter and they cost nothing to choose. And don't launch a backfill project that produces coverage nobody reads, because it competes with the work that's actually paying for the team.
Should I write tests in a take-home?
Yes, a few, and put a line in the readme saying what you'd add with more time. Reviewers often read the test names before they read the code, because the names say what you thought the problem was. Skipping tests entirely reads as a gap in judgment even when the solution is right, and the same reviewer instinct shows up in the other formats too.
How do you test async code without it turning flaky?
Wait for a condition, never for a duration. Await the promise where you can, and where time itself is the input, control the clock with fake timers instead of letting the real one participate. If you truly have to poll, poll for the state you want with a timeout, so the test fails with a description of what never happened rather than at an arbitrary line after a sleep.