Unit tests

Use test doubles sparingly

Choose fakes, stubs, spies, and mocks for a precise reason without replacing the behavior the test is supposed to prove.

A test double is anything that stands in for a real dependency during a test. People say “mock” for all of them, but there are three different tools here, and they answer different questions.

A fake has real but simplified behavior, like an in-memory repository that stores books in an array. A stub returns a prepared answer, like “throw this error”. A spy records that it was called, and with what.

Which one, when

I reach for a fake when state matters. Create a book, then read it back: the in-memory array does that fine.

I reach for a stub to trigger something rare. A duplicate ISBN error is hard to produce on demand with a fake, so a stub throws it directly:

const repository = {
  insert: async () => {
    throw new DuplicateIsbnError('9780441172719')
  }
}

const result = await createBook(input, { repository })
assert.deepEqual(result, {
  ok: false,
  code: 'duplicate-isbn'
})

This stub makes a rare failure deterministic. That’s its whole job.

I reach for a spy only when the interaction is the contract. “Send exactly one confirmation email after the transaction commits” is a spy’s question. “The book was saved” is not: seeing that insert() was called doesn’t prove the database accepted the row.

Contract drift

The danger with all doubles is that they drift from the real thing. Say the real repository returns null for a missing book and your fake returns undefined. Every test passes, and production behaves in a way no test ever saw.

Two habits limit this. Keep the doubles behind a narrow interface your application owns, like the insert() and findById() we defined. And run separate integration tests against the real adapter, so the fake and the real thing get compared regularly.

Over-mocking

The worst mock is one that copies the implementation into the test. The test expects the exact sequence of internal calls it was taught to expect. Change the order of two lines and it fails. Break the actual behavior and it passes.

Prefer asserting on what comes out: return values, state changes, the response body. Spy on interactions only at an external boundary, where the call itself is the observable outcome.

Also, never mock your own simple pure functions. Call them. They’re fast.

Try this: test that a storage conflict becomes the documented API conflict response, without a database. Then add one real-database integration test that proves the uniqueness constraint produces the error your stub pretends to throw. If those two disagree, the stub is lying.

Lesson completed