Skip to content
academia.sh

Lesson 06 / 13

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.

Contents

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.

// 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.

// 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.

// 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();
});
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.

// 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.

// 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']);
});
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close