Skip to content
QSWEQB
mediumConceptDesignMidSeniorStaff

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.

5 min readUpdated 2026-07-27

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, numeric versus 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

Further reading

testingmockingvitestjestintegration-testing