How do you test a Node service, and what do you refuse to mock?
Drive business logic through injected dependencies rather than module mocks, intercept at the process boundary for outbound HTTP and time, and use a real database in a container for anything where the database is the logic — SQL, constraints, transactions and migrations.
What the interviewer is scoring
- Does the candidate reach for dependency injection before module mocking, and can they say why the seam matters
- Whether they know that ESM live bindings are what makes module mocking awkward, rather than treating it as a tooling bug
- That the runner choice is argued from constraints — existing config, ESM, zero dependencies — not from preference
- Whether they can name specific behaviour that only a real database exercises, beyond "integration tests are good"
- Does the candidate address test isolation under parallel workers, which is where a real-database suite usually falls over
Answer
Design the seams before choosing the tools
The quality of a Node test suite is decided by where the substitution points are, not by which runner sits on top. If a module reaches out and creates its own database client, HTTP client and clock, the only way to test it is to reach into the module system and rewrite its imports. If those three arrive as arguments, you need no mocking framework at all.
type User = { id: string; email: string };
type Deps = {
repo: { findByEmail(email: string): Promise<User | null> };
mailer: { send(to: string, body: string): Promise<void> };
now: () => Date;
};
// The seam is the parameter list. A test passes three plain objects;
// production passes Postgres, SES and Date.now.
export async function inviteUser(email: string, deps: Deps): Promise<void> {
const existing = await deps.repo.findByEmail(email);
if (existing) return;
await deps.mailer.send(email, `Invited at ${deps.now().toISOString()}`);
}
Say this before naming a library, because the follow-up an interviewer wants to ask
is "how would you test this without touching the module system", and a candidate
whose only answer is jest.mock has given away that every unit of their code
constructs its own collaborators.
Mocking module boundaries, and why ESM changed it
Sometimes you genuinely cannot inject — a third-party package read at import time, a transitive dependency, legacy code you are not refactoring today. Then you replace the module itself, and it matters which module system you are in.
Under CommonJS this is easy, and that is why an entire generation of tooling assumed
it. require.cache is an ordinary mutable object, and module.exports is a value
you can overwrite, so a runner can swap an implementation between requires. Under
ESM you cannot: an import is a live binding to the exporting module's variable and it
is read-only from the importing side, so there is nothing to assign to. Substitution
has to happen during resolution, which means loader hooks — module.register() in
Node, or a runner that owns the transform.
That is the real reason jest.mock and vi.mock behave the way they do. They look
like function calls but they are rewritten at build time and hoisted above your
imports, because by the time a normal statement runs the module graph is already
instantiated. It also explains why the same mock written by hand fails: the call
order you can see is not the call order that executes.
Two boundaries are better mocked at the process edge than in the module graph.
Outbound HTTP should be intercepted at the transport, with undici's MockAgent
installed as the global dispatcher so that fetch calls made anywhere — including
inside a vendor SDK — are answered from your fixtures. Time should be handled with
the runner's fake timers rather than by threading a clock through everything, once
you have more than a couple of call sites.
Jest, Vitest, or node:test
There is no universally right answer, which is the point of the question. Argue from constraints.
Jest has the largest ecosystem and the most existing configuration in the world,
and if a team already runs it there is rarely a case for churn. Its weakness is that
it was designed around CommonJS and Babel: native ESM support still requires
--experimental-vm-modules and a separate mocking API, and every TypeScript file
passes through a transform you have to configure and keep fast.
Vitest is the default for a new service in an ESM and TypeScript codebase,
particularly one already using Vite. It shares the application's resolution and
plugin configuration, so aliases and path mappings work without a second copy of
them, its watch mode reruns only what the module graph says was affected, and the
expect and vi APIs are close enough to Jest's that migration is mostly
mechanical. The honest cost is that your tests run through Vite's transform rather
than Node's own resolution, so it is possible — rarely, but it happens — for a suite
to pass while the deployed artefact does not.
node:test is built in and stable since Node 20, needs no dependencies, and
runs your code through exactly the loader that production uses. For libraries, CLI
tools, and anything where you care about supply chain and startup time, that is a
strong argument. Its limitation is the mocking story: function, method and timer
mocks are solid, but module mocking is still experimental, and there is no bundled
transform, so a TypeScript project leans on Node's own type stripping or a build
step. Coverage arrived as --experimental-test-coverage and is thinner than what
the other two report.
What a mock can never tell you about your database
The tests worth arguing over are the ones people replace with a mocked ORM client. A repository test that stubs Prisma or Knex asserts that you called your own stub with the arguments you told it to expect. It cannot fail for any reason a production query fails for, so it is a test of the test.
Use a real database — a container, started once per suite, migrated on startup — for everything where the database is the logic:
- SQL the ORM generated on your behalf, including whether a filter was translated or silently evaluated in application memory after fetching the table
- constraints: unique, foreign key, check, and the exact error a violation raises, because your code branches on it
- upsert and conflict semantics, which are dialect-specific and easy to get subtly wrong
- transaction boundaries, isolation level behaviour, and deadlocks between two concurrent code paths
- cascade behaviour on delete, and what happens to dependent rows
- type fidelity: timestamp with and without time zone,
numericversus floating point, and how the driver hands each back to JavaScript - the migrations themselves, which are code that runs exactly once in production and is therefore the least-tested code you own
Running the migrations as suite setup is the cheapest high-value test in the list, because it is the only place a broken migration surfaces before it surfaces in production.
Where a real-database suite usually collapses
It is not speed, it is isolation under parallelism. Jest and Vitest both run test files concurrently by default, so several files sharing one database will interleave inserts and truncations, and you get a suite that passes locally, fails in CI, then passes on retry. Nobody trusts it afterwards, and an untrusted suite gets skipped.
Pick one strategy and apply it everywhere. Wrap each test in a transaction and roll it back, which is fast and leaves nothing behind but forbids testing anything that manages its own transactions. Or give each worker its own database, created from a migrated template so setup does not re-run migrations per worker. Or truncate a known set of tables between tests. What you must not do is depend on data another test inserted, or on the order files happen to run in.
Mock the things you do not own and cannot afford to call; inject the things you do own; and run the database for real, because a mocked query has no failure modes and a real one has all of the interesting ones.
Likely follow-ups
- Your suite is green but a release broke on a unique-constraint violation. Where was the gap, and what test would have caught it?
- How do you keep a Postgres-backed suite fast enough to run on every commit?
- When is a contract test the right answer instead of either a mock or a live dependency?
- How would you test code that depends on the current time and on a 30-second timeout?
Related questions
- How do you test a service that calls a third-party API, without calling it?mediumAlso on testing4 min
- What is the test pyramid, and is it still the right model for your system?mediumAlso on integration-testing4 min
- You own the API test suite for a service from scratch. How do you structure it, and what do you mock?hardAlso on mocking7 min
- Go gives you no project layout and no dependency-injection container. How do you structure a service so it stays testable as it grows?mediumAlso on testing5 min
- Your test still reaches the network even though you patched the client. Talk me through fixture scope, parametrisation, and where a patch has to point.mediumAlso on mocking4 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How does Node load your modules, and how would you make a Node service use more than one core?mediumSame kind of round: concept6 min