---
title: 'Test Doubles'
source: 'https://academia.sh/en/courses/unit-testing/mock-dependencies'
course: 'Unit Testing and Test-Driven Development'
language: en
updated: '2026-08-23T14:25:21+00:00'
license: 'CC BY-SA 4.0'
---

# Test Doubles

Hand-writing the dummy, stub, fake, spy, and mock types, separating the verification question each type answers, and measuring which type speaks up at the same change.

In the previous lesson, the dependencies supplied from outside were small functions that
returned a fixed value. That is a single member of a family. The common name for objects
put in place of a real dependency in the tested code is **test double**, and the
difference between them is not one of structure but of **purpose**: one only fills a
parameter's place, one gives a specific answer, one is a working copy of the real
behavior, one records calls, and one carries the expectation inside itself.

These distinctions are not academic. Each type answers a different verification question,
and when the wrong type is chosen, a test either verifies nothing or ends up bound to
something it did not mean to verify. This lesson hand-writes all five types and, at the
end, tests all of them against the same change.

## The Service Under Test

The loan service takes four dependencies: the catalog holding records, the notification
channel that informs the member, the transaction log, and the clock that gives the day
number.

```js
// service.mjs — loan service: catalog, notification, log, and clock dependencies
export const RULES = {
  student: { loanDays: 28, maxLoans: 10 },
  member: { loanDays: 14, maxLoans: 5 },
};

export function createLoanService({ catalog, notification, log, clock }) {
  return {
    lendBook(member, bookId) {
      const rule = RULES[member.type];
      if (catalog.memberLoanCount(member.id) >= rule.maxLoans) {
        throw new Error(`loan limit exceeded: ${rule.maxLoans}`);
      }
      const today = clock();
      const record = { memberId: member.id, bookId, borrowedDay: today, dueDay: today + rule.loanDays };
      catalog.add(record);
      notification.send(member.id, `due date: ${record.dueDay}`);
      return record;
    },

    checkIn(bookId) {
      const record = catalog.find(bookId);
      if (record === undefined) throw new Error(`no loan record: ${bookId}`);
      catalog.remove(bookId);
      log.write(`return ${bookId}`);
      return record;
    },
  };
}
```

## Writing the Five Types

All five are a few lines each; none of them requires a library.

```js
// doubles.mjs — four test-double types and an expectation-carrying mock
export function dummy(name) {
  return new Proxy({}, {
    get: (_target, prop) => () => {
      throw new Error(`${name}.${String(prop)} should not have been called`);
    },
  });
}

export function stubCatalog(loanCount) {
  return {
    memberLoanCount: () => loanCount,
    add() {},
    find: () => undefined,
    remove() {},
  };
}

export function fakeCatalog() {
  const records = new Map();
  return {
    memberLoanCount: (memberId) => [...records.values()].filter((r) => r.memberId === memberId).length,
    add: (record) => records.set(record.bookId, record),
    find: (bookId) => records.get(bookId),
    remove: (bookId) => records.delete(bookId),
  };
}

export function spy(...methods) {
  const calls = [];
  const obj = { calls };
  for (const name of methods) obj[name] = (...args) => calls.push([name, ...args]);
  return obj;
}

export function mock(expected, ...methods) {
  const spyObj = spy(...methods);
  return {
    ...spyObj,
    verify() {
      const actual = JSON.stringify(spyObj.calls);
      const exp = JSON.stringify(expected);
      if (actual === exp) return;
      throw new Error(`expectation not met: expected ${exp}, actual ${actual}`);
    },
  };
}
```

**Dummy** only fills a parameter's place; it is expected not to be called. The version
here enforces that expectation: it throws an error whenever any of its methods is called.

**Stub** fixes a value that the tested code reads. Its question is: which path does the
code take with this input? The stub itself verifies nothing.

**Fake** is a working but simplified copy of the real dependency. The catalog here uses a
real map; adding, finding, and removing are meaningful. Multi-step scenarios can only be
built with this type.

**Spy** records calls and leaves them for the test to inspect afterward. Its question is:
was this call actually made, and with which arguments?

**Mock** carries the expectation inside itself. Its difference from a spy is that
verification is the object's responsibility, not the test's.

## The Question Each Type Answers

When the five types are used in five separate tests, their differences become visible.

```js
// doubles.test.mjs — each test-double type answers a separate question
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createLoanService } from './service.mjs';
import { dummy, stubCatalog, fakeCatalog, spy, mock } from './doubles.mjs';

const STUDENT = { id: 'U-17', type: 'student' };
const MEMBER = { id: 'U-42', type: 'member' };

test('dummy: lending never touches the log', () => {
  const service = createLoanService({
    catalog: fakeCatalog(),
    notification: spy('send'),
    log: dummy('log'),
    clock: () => 1000,
  });

  const record = service.lendBook(STUDENT, 'K-903');

  assert.equal(record.dueDay, 1028);
});

test('stub: a loan is rejected for a member at the limit', () => {
  const service = createLoanService({
    catalog: stubCatalog(5),
    notification: dummy('notification'),
    log: dummy('log'),
    clock: () => 1000,
  });

  assert.throws(() => service.lendBook(MEMBER, 'K-903'), /loan limit exceeded: 5/);
});

test('fake: a returned book can be lent out again', () => {
  const service = createLoanService({
    catalog: fakeCatalog(),
    notification: spy('send'),
    log: spy('write'),
    clock: () => 1000,
  });

  service.lendBook(STUDENT, 'K-903');
  service.checkIn('K-903');
  const second = service.lendBook(MEMBER, 'K-903');

  assert.equal(second.dueDay, 1014);
});

test('spy: a single notification goes to the member', () => {
  const spyObj = spy('send');
  const service = createLoanService({
    catalog: fakeCatalog(),
    notification: spyObj,
    log: dummy('log'),
    clock: () => 1000,
  });

  service.lendBook(STUDENT, 'K-903');

  assert.deepEqual(spyObj.calls, [['send', 'U-17', 'due date: 1028']]);
});

test('mock: no notification goes out on a rejected loan', () => {
  const mockObj = mock([], 'send');
  const service = createLoanService({
    catalog: stubCatalog(5),
    notification: mockObj,
    log: dummy('log'),
    clock: () => 1000,
  });

  assert.throws(() => service.lendBook(MEMBER, 'K-903'));

  mockObj.verify();
});
```

```sh
node --test --test-reporter=tap doubles.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - dummy: lending never touches the log
ok 2 - stub: a loan is rejected for a member at the limit
ok 3 - fake: a returned book can be lent out again
ok 4 - spy: a single notification goes to the member
ok 5 - mock: no notification goes out on a rejected loan
# tests 5
# pass 5
# fail 0
```

The five tests' assert sections differ from one another, and the difference comes from the
type chosen. The first test's assertion concerns the returned record; the dummy only
protects an assumption. The second test also looks at the return value; the stub supplies
the input that makes that return possible. The third test builds a three-step scenario,
and that can only happen with a stateful fake. The fourth and fifth tests look not at the
return value but at **the call that was made**.

The distinction between the last two tests is subtle. The test using a spy does its
verification in its own body, and the assertion reads explicitly. The test using a mock
sets up the expectation in the fixture and calls only `verify()` in its body. The second
shortens the body when there are multiple call expectations; in exchange, where the
expectation is set up moves to the start of the test.

## Who Speaks Up at the Same Change

The types' real difference is which one makes noise when the code changes. The script
below adds two new side effects to the service — every loan attempt is written to the log,
and the member is now notified on a rejected request too — and applies the same three
types to this new version.

```js
// probe.mjs — which test double speaks up when the service gains a side effect
import assert from 'node:assert/strict';
import { RULES } from './service.mjs';
import { dummy, stubCatalog, mock } from './doubles.mjs';

function createChangedService({ catalog, notification, log, clock }) {
  return {
    lendBook(member, bookId) {
      const rule = RULES[member.type];
      log.write(`loan attempt ${bookId}`);
      if (catalog.memberLoanCount(member.id) >= rule.maxLoans) {
        notification.send(member.id, 'loan rejected');
        throw new Error(`loan limit exceeded: ${rule.maxLoans}`);
      }
      const today = clock();
      const record = { memberId: member.id, bookId, borrowedDay: today, dueDay: today + rule.loanDays };
      catalog.add(record);
      notification.send(member.id, `due date: ${record.dueDay}`);
      return record;
    },
  };
}

const MEMBER = { id: 'U-42', type: 'member' };
const silentLog = { write() {} };
const silentNotification = { send() {} };

function attempt(label, body) {
  try {
    body();
    console.log(`${label.padEnd(12)} silent`);
  } catch (error) {
    console.log(`${label.padEnd(12)} ${error.message.split('\n')[0]}`);
  }
}

attempt('stub', () => {
  const service = createChangedService({
    catalog: stubCatalog(5),
    notification: silentNotification,
    log: silentLog,
    clock: () => 1000,
  });

  assert.throws(() => service.lendBook(MEMBER, 'K-903'), /loan limit exceeded: 5/);
});

attempt('dummy', () => {
  const service = createChangedService({
    catalog: stubCatalog(0),
    notification: silentNotification,
    log: dummy('log'),
    clock: () => 1000,
  });

  service.lendBook(MEMBER, 'K-903');
});

attempt('mock', () => {
  const mockObj = mock([], 'send');
  const service = createChangedService({
    catalog: stubCatalog(5),
    notification: mockObj,
    log: silentLog,
    clock: () => 1000,
  });

  assert.throws(() => service.lendBook(MEMBER, 'K-903'));
  mockObj.verify();
});
```

```
stub         silent
dummy        log.write should not have been called
mock         expectation not met: expected [], actual [["send","U-42","loan rejected"]]
```

The three lines tell, on their own, what the type choice means.

The stub stayed **silent**, because the return value and the thrown error had not changed.
This is not a defect: a stub only supplies input, it does not verify. A test that looks at
the return value does not see the new side effects, and should not.

The dummy **spoke up**, because a dependency that should not be called was called. This is
this type's only function, and in exchange it turns the assumption the test rests on into
documentation.

The mock also **spoke up**, because its expectation was "no notification should go out"
and a notification went out. A spy would have given the same signal; the difference is
where the verification sits.

The selection rule that follows from this has three parts. If what is to be verified is
the **return value or the thrown error**, a stub is enough. If **a side effect happening**
is to be verified, a spy or a mock is needed. If **a multi-step scenario** is to be built,
a fake is needed; multi-step scenarios built with a stub quickly become inconsistent.

Call verification carries a cost: the test becomes bound to **how** the tested code works.
If a refactor moves the notification send to another layer, tests written with a spy or a
mock fail, even though the externally observed behavior is the same. The eighth lesson
measures this cost.

## The Capability the Runner Provides

The built-in test runner provides a capability so that call-recording functions do not
have to be hand-written. It does the same job as a hand-written spy; the record format
comes ready-made.

```js
// builtin.test.mjs — the mock-function capability the runner provides
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createLoanService } from './service.mjs';
import { dummy, fakeCatalog } from './doubles.mjs';

test('the built-in mock function keeps a call record', () => {
  const send = mock.fn();
  const service = createLoanService({
    catalog: fakeCatalog(),
    notification: { send },
    log: dummy('log'),
    clock: () => 1000,
  });

  service.lendBook({ id: 'U-17', type: 'student' }, 'K-903');

  assert.equal(send.mock.callCount(), 1);
  assert.deepEqual(send.mock.calls[0].arguments, ['U-17', 'due date: 1028']);
});
```

```sh
node --test --test-reporter=tap builtin.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
```

```
ok 1 - the built-in mock function keeps a call record
# tests 1
# pass 1
# fail 0
```

The ready-made capability reduces the writing burden, but it does not make the type
choice. Because `mock.fn()` keeps a call record, it can be used as a spy or as a mock;
which one it is depends on how the test verifies it. The decision that chooses the type
is still the writer's.

## Summary

- Test doubles differ in purpose, not structure; the five types answer five separate
  verification questions.
- A dummy only fills a place, a stub fixes input, a fake is a working copy, a spy records
  calls, and a mock carries the expectation itself.
- When two side effects were added to the service, the stub stayed silent, the dummy
  reported the unexpected call, and the mock reported the unmet expectation.
- If the return value is to be tested, a stub is enough; if a side effect is to be tested,
  a spy or a mock is needed; if a multi-step scenario is to be built, a fake is needed.
- Call verification binds the test to how the code works; the ready-made capability the
  runner provides reduces the writing burden but does not make the type choice.

## Next Step

In this lesson, what the tests verified was separated type by type, but the question of
**how much** of the codebase they verified was never asked. The common answer to this
question is a percentage: what fraction of the code ran during testing? The next lesson
shows that this percentage is calculated in three separate ways and counts all three by
hand. A bug that a codebase reaching one hundred percent statement coverage missed is
shown, run in the same lesson.
