open atlas
↑ Back to track
NestJS, zero to senior NEST · 06 · 03

Mocking, test doubles and the over-mocking trap

Stub returns canned data, mock verifies behaviour, fake is a working impl. Override Nest deps with overrideProvider, not jest.mock. The over-mocking trap: mock boundaries you do not own, fake or integrate what you do — else a green suite tests your assumptions, not your code.

NEST Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The suite was a green wall. Two hundred unit tests, every one of them passing in 900ms, and a UsersService spec that mocked the repository’s findOne to return { id: 1, email: 'a@b.com' }. Then a support ticket: GET /users/9999 returns a 500, not a 404. The stack trace reads Cannot read properties of null (reading 'email'). The service does const user = await repo.findOne(...); return { email: user.email } — and the real query returns null for a missing id. The test never returned null, because the mock was told to return a user, always. The test passed because it asserted that our assumption about findOne was internally consistent — not that the real repository behaves that way. The mock was lying, politely, two hundred times a run. This lesson is about which test double to reach for, how to swap a real dependency in Nest, and the line a senior draws between mocking and over-mocking.

The test-double vocabulary, used precisely

Why does the vocabulary matter? Because when you call something a “mock” and reach for jest.fn(), you’ve already decided what the test verifies — and that decision can be wrong in a way that only shows up in production. Choosing the word precisely forces you to choose the behaviour precisely.

“Mock” is the word everyone uses for everything, and that sloppiness hides what a test actually verifies. There are five distinct kinds, and the difference is the difference between state verification (assert the result) and behaviour verification (assert the call happened).

  • A dummy is passed to satisfy a signature but never used — a filler argument.
  • A stub returns canned answers. You use it for state verification: program it to return a user, then assert on what your code computed from that user. It doesn’t care if it was called.
  • A spy records how it was called (and can optionally call through to the real thing). You assert it was called, with what arguments, how many times.
  • A mock has pre-programmed expectations and fails the test if they aren’t met. It’s behaviour verification: the assertion is baked into the double itself.
  • A fake is a working lightweight implementation — an in-memory repository backed by a Map, a fake clock. It actually behaves, just without the IO.
// stub: canned return, used for STATE verification (assert on the computed result)
const repoStub = { findOne: jest.fn().mockResolvedValue({ id: 1, email: 'a@b.com' }) };

// spy: assert it WAS called, with what (behaviour, but read-only)
const mailSpy = jest.fn();
expect(mailSpy).toHaveBeenCalledWith('a@b.com');

// fake: a real, working in-memory impl — it enforces its own contract
class FakeUserRepo {
  private rows = new Map<number, User>();
  async findOne({ where: { id } }: any) { return this.rows.get(id) ?? null; } // returns null!
  async save(u: User) { this.rows.set(u.id, u); return u; }
}

Notice the fake’s findOne returns null for a missing id — because that’s what the real one does. A stub that always returns a user can never produce that path. Choosing the word precisely forces you to choose the behaviour precisely. Together, these five types cover every testing need: stub and fake for state verification, spy and mock for behaviour verification, dummy for scaffolding. Miss the fake and you lose the only double that actually enforces the real contract.

Overriding providers in Nest: override, don’t jest.mock

You already build the testing module with Test.createTestingModule. The mocking-specific move is swapping a real provider for a double through Nest’s DI, with overrideProvider(...).useValue(...) before .compile(). This matters because the swap goes through the container — the SUT gets the double by the same token it would get the real thing, and nothing else in the graph notices.

const mailMock = { send: jest.fn() };
const repoMock = { findOne: jest.fn(), save: jest.fn() };

const moduleRef = await Test.createTestingModule({
  providers: [UsersService, MailService /* real token, will be overridden */],
})
  .overrideProvider(MailService).useValue(mailMock)
  // a TypeORM repo is injected by a TOKEN, not the class — override the token
  .overrideProvider(getRepositoryToken(User)).useValue(repoMock)
  .compile();

const service = moduleRef.get(UsersService);

The repository line is the one people get wrong: @InjectRepository(User) injects under getRepositoryToken(User), a string-ish token, not the Repository class. Override the token, not Repository. And resist jest.mock('@nestjs/typeorm') for this — module-level mocking fights Nest’s DI: it replaces the symbol globally before the container wires anything, so you lose the per-test, per-token control and end up debugging why an unrelated test in the same file now sees your mock. overrideProvider is scoped to this testing module and speaks DI’s language.

Auto-mocking: stop hand-writing empty objects

Hand-writing { findOne: jest.fn(), save: jest.fn(), find: jest.fn(), ... } for a wide dependency is tedious and goes stale. Nest’s useMocker hooks a factory for any unprovided dependency; paired with @golevelup/ts-jest’s createMock, it auto-generates a deep, fully-typed mock of the entire interface, and you program only the methods the test touches.

import { createMock } from '@golevelup/ts-jest';

const moduleRef = await Test.createTestingModule({ providers: [UsersService] })
  .useMocker(createMock) // every missing dep becomes a deep auto-mock typed to its interface
  .compile();

const repo = moduleRef.get(getRepositoryToken(User));
repo.findOne.mockResolvedValue({ id: 1, email: 'a@b.com' }); // program just what you need

The convenience has a sharp edge: a deep auto-mock is typed to the interface, so it will happily produce a jest.fn() for a method that no longer exists on the real type, or one whose signature drifted. The mock satisfies the compiler against a shape that reality has since left behind — which is exactly the failure the next section is about.

Why this works

Why do fully-mocked unit tests give false confidence? Because a mock returns what you told it to return. The test then asserts that your code, fed your assumption, produces a result consistent with that same assumption — a closed loop. It proves the code is internally coherent with your mental model of the dependency; it proves nothing about whether the dependency actually behaves that way. The moment the real contract drifts — findOne now returns null on miss, save now enforces a unique constraint, a method now throws on a bad input — the mock keeps serving the old happy answer and the test stays green while production breaks. Only a fake (which implements the contract) or an integration test (which runs the real thing) notices the drift. A mock can’t tell you that you’re wrong about the world, because you wrote down what it says.

The over-mocking trap

A mock encodes your assumption of how a dependency behaves. That’s fine for a boundary you don’t own — a payment gateway, an email provider, a third-party HTTP API — where you can’t run the real thing in a unit test and the contract is stable and external. It’s a trap for the things you do own.

The canonical failure is the one from the Hook. The repo mock returns { id, email } always; the real query returns null on not-found. The service does user.email. The test is green; prod throws Cannot read properties of null. A sibling: a mocked save never enforces the real UNIQUE(email) constraint, so a test for “reject duplicate email” passes against a mock that cheerfully saves twice — the test can’t catch a duplicate because the mock has no constraint to violate. In both cases the test is testing the mock, not the code. Over-mock and your green suite measures the fidelity of your assumptions, not the correctness of your system.

The senior stance is a boundary, drawn deliberately:

  • Mock at architectural boundaries you don’t own — third-party HTTP, email, payments, queues. You can’t run them in a unit test; their contract is external and rarely changes under you.
  • Fake or integrate the things you do own — your repository, your domain services. Use an in-memory fake that honours the contract (returns null on miss, enforces uniqueness), or a real-DB integration test. These are the parts most likely to drift, so they’re the parts a mock most dangerously freezes.
  • Keep mocks honest with integration tests that exercise the real contract. Unit tests with mocks run in single-digit milliseconds because there’s no IO — that speed is the entire reason to mock. The cost is fidelity. You buy the speed where fidelity is cheap (stable boundaries) and pay for fidelity where it matters (your own data layer), with a thinner layer of integration tests pinning the real contract so a drift turns a test red instead of a pager.
Pick the best fit

You must test a UsersService that depends on a TypeORM user repository AND a third-party EmailService (sends a welcome email). How do you choose the doubles so the test is both fast and honest?

Quiz

A UsersService is injected with a TypeORM repository via @InjectRepository(User). In the testing module, which provider do you override to swap the repo for a double?

Quiz

A service test mocks the repo's findOne to return a user, and asserts the returned email. In prod, GET /users/9999 throws 'Cannot read properties of null (reading email)'. Why didn't the test catch it?

Recall before you leave
  1. 01
    Distinguish dummy, stub, spy, mock and fake, and say which do state vs behaviour verification.
  2. 02
    Explain the over-mocking trap and the boundary a senior draws, with the null-deref and unique-constraint examples.
Recap

“Mock” is one word for five distinct doubles, and the sloppiness hides what a test verifies. A dummy fills a signature; a stub returns canned data for STATE verification; a spy records that a call happened; a mock carries pre-programmed expectations for BEHAVIOUR verification; a fake is a working lightweight implementation that honours the real contract. In Nest you swap a real dependency through DI with Test.createTestingModule(…).overrideProvider(MailService).useValue(mock).compile(), and for a TypeORM repository you override the TOKEN — overrideProvider(getRepositoryToken(User)).useValue(repoMock) — not the Repository class; prefer this over jest.mock, which fights DI by replacing symbols globally. useMocker(createMock) auto-generates a deep typed mock of every dependency so you program only the methods the test touches — convenient, but it will mock methods that no longer exist if the type drifts. That drift is the over-mocking trap: a mock encodes your assumption, so a green fully-mocked suite asserts your assumptions are self-consistent, not that reality matches them — a repo mock that always returns { id, email } hides the null-on-not-found path (prod throws on user.email), and a mocked save hides the missing UNIQUE constraint (the duplicate slips through). The senior boundary: mock the boundaries you don’t own (third-party HTTP, email, payments — stable, external, fast in ms), fake or integration-test the things you do own (your repo and domain — most likely to drift), and keep a thin layer of integration tests exercising the real contract so a drift turns a test red instead of a pager. Now when you see a green suite that’s never returned null from findOne, ask yourself: is that a passing test, or a well-formatted assumption?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.