Skip to content
academia.sh

Lesson 05 / 13

Testable Design

Comparing a version that creates its own dependency with a version that receives it from outside, measuring fixture cost in lines, and handing off time and randomness from outside.

Contents

In the previous lesson the fixture was in the test’s hands: the catalog object was produced by a factory call, and each test built its own copy. That option came from the code, not the test. If the tested code created the catalog inside itself, the test would have no lever left to change it.

Testability is not a property of the test — it is a property of the design. The same business rule written in two different ways can both run correctly, but one leaves a testable lever and the other does not. This lesson’s question is: where does that difference show up, and how much does it cost?

Same Rule, Two Designs

The business logic to test is this: a loan operation reads today’s day number, produces a receipt number, checks that the book is not already on loan, and returns the loan record. The operation has three outside sources: the clock, the receipt-number generator, and the catalog.

The first version creates all three inside itself.

// internal.mjs — version that creates its dependencies inside itself
const catalog = new Map();

export function clearCatalog() {
  catalog.clear();
}

export function lendBook(memberId, bookId, memberType) {
  const today = Math.floor(Date.now() / 86400000);
  const receiptId = `F-${String(Math.floor(Math.random() * 1000000)).padStart(6, '0')}`;
  if (catalog.has(bookId)) throw new Error(`book already on loan: ${bookId}`);
  const loanDays = memberType === 'student' ? 28 : 14;
  catalog.set(bookId, memberId);
  return { receiptId, memberId, bookId, borrowedDay: today, dueDay: today + loanDays };
}

The second version receives all three from outside. The business rule — the duration calculation, the double-loan check, the record shape — is identical.

// external.mjs — version whose dependencies are supplied from outside
export function createLoanService({ clock, receiptGenerator, catalog }) {
  return {
    lendBook(memberId, bookId, memberType) {
      const today = clock();
      const receiptId = receiptGenerator();
      if (catalog.has(bookId)) throw new Error(`book already on loan: ${bookId}`);
      const loanDays = memberType === 'student' ? 28 : 14;
      catalog.set(bookId, memberId);
      return { receiptId, memberId, bookId, borrowedDay: today, dueDay: today + loanDays };
    },
  };
}

This second form is called dependency injection; the dependency is handed in from outside instead of being created inside. The name sounds like a framework feature, but as seen here it is only a signature decision: the code expects its dependency to be given to it instead of reaching out for it.

The Cost of the Fixture

Both versions’ tests exercise the same rule; what differs is the fixture that has to be built to reach that rule.

// fixture.mjs — the fixture each version's test has to build
import { clearCatalog, lendBook } from './internal.mjs';
import { createLoanService } from './external.mjs';

export function internalFixture() {
  const realNow = Date.now;
  const realRandom = Math.random;
  Date.now = () => 1000 * 86400000;
  Math.random = () => 0.123456;
  clearCatalog();
  const restore = () => {
    Date.now = realNow;
    Math.random = realRandom;
  };
  return { service: { lendBook }, restore };
}

export function externalFixture() {
  const service = createLoanService({
    clock: () => 1000,
    receiptGenerator: () => 'F-123456',
    catalog: new Map(),
  });
  return { service, restore: () => {} };
}

The size of the fixtures can be measured. When both fixtures are run and the records they produce are compared, the difference turns out to be in the preparation, not the behavior.

// measure.mjs — the two fixtures' line count and the records they produce
import { internalFixture, externalFixture } from './fixture.mjs';

const lines = (fn) => fn.toString().split('\n').length - 2;

for (const [label, fixtureFn] of [['internal', internalFixture], ['external', externalFixture]]) {
  const { service, restore } = fixtureFn();
  const record = service.lendBook('U-17', 'K-903', 'student');
  restore();
  console.log(`${label.padEnd(10)} fixture ${String(lines(fixtureFn)).padStart(2)} lines  ${JSON.stringify(record)}`);
}
internal   fixture 10 lines  {"receiptId":"F-123456","memberId":"U-17","bookId":"K-903","borrowedDay":1000,"dueDay":1028}
external   fixture  6 lines  {"receiptId":"F-123456","memberId":"U-17","bookId":"K-903","borrowedDay":1000,"dueDay":1028}

The two records are identical. The fixture cost is ten lines against six. But the real difference is not the line count — it is what those lines do. The externally-supplied version’s fixture only builds an object. The internally-creating version’s fixture replaces two global functions, clears the tested module’s internal state, and takes on the job of undoing what it changed.

The first lesson said that “a growing arrange section is a separate signal.” This is the signal being measured here: as the fixture grows, the rule the test exercises does not shrink, but the risk entering the test grows.

The Cost of an Undone Change

The risk carried by a fixture that changes a global function is concrete. In the test file below, the first test freezes the clock and forgets to restore it.

// leak.test.mjs — an undone global change affects the next test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clearCatalog, lendBook } from './internal.mjs';

test('a student loan lasts twenty-eight days', () => {
  Date.now = () => 1000 * 86400000;
  clearCatalog();

  const record = lendBook('U-17', 'K-903', 'student');

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

test('the day number comes from the real clock', () => {
  const today = Math.floor(Date.now() / 86400000);

  assert.ok(today > 20000, `unexpected day number: ${today}`);
});
node --test --test-reporter=tap leak.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - a student loan lasts twenty-eight days
not ok 2 - the day number comes from the real clock
# tests 2
# pass 1
# fail 1

The second test is correct on its own and passes when run alone. In the run where it fails, the day number it sees is one thousand, because the clock is still frozen where the first test left it. This is the same order dependence as in the previous lesson, but its source differs: the shared state is not sitting inside the library itself, it is accumulating in the runtime’s global objects.

Undoing the global change is one fix, but it is a fix that can be forgotten, and when it is forgotten, the test that breaks is not the test that made the mistake. In the externally-supplied version, this risk class never arises.

// clean.test.mjs — an externally-supplied dependency does not corrupt global state
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createLoanService } from './external.mjs';

const setup = () => createLoanService({
  clock: () => 1000,
  receiptGenerator: () => 'F-123456',
  catalog: new Map(),
});

test('a student loan lasts twenty-eight days', () => {
  const service = setup();

  const record = service.lendBook('U-17', 'K-903', 'student');

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

test('the day number comes from the real clock', () => {
  const today = Math.floor(Date.now() / 86400000);

  assert.ok(today > 20000, `unexpected day number: ${today}`);
});

test('the receipt number comes from an external source', () => {
  const service = setup();

  const record = service.lendBook('U-17', 'K-903', 'member');

  assert.equal(record.receiptId, 'F-123456');
});
node --test --test-reporter=tap clean.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - a student loan lasts twenty-eight days
ok 2 - the day number comes from the real clock
ok 3 - the receipt number comes from an external source
# tests 3
# pass 3
# fail 0

The third test checks something neither of the first two versions could ever have checked: where the receipt number comes from. In the internally-creating version the receipt number comes from the randomness source, and verifying it would again require a global change. In the externally-supplied version the generator is a parameter, and the value the test gives it lands in the record exactly as given.

Where Non-Deterministic Sources Belong

Time and randomness are two typical examples of sources a test cannot control; the same class also includes process IDs, environment variables, network addresses, and the file system. What they share is that they do not give the same output for the same input.

The way to make these testable is not to block the code from reaching these sources, but to gather that access at a single point that can then be supplied from outside. In the version above, clock and receiptGenerator are those points. Configuration running in production supplies the real sources; configuration running in tests supplies fixed values.

This separation has a limit, and it should be said up front: if a piece of code genuinely has to read the time, that read has to be tested somewhere too. Supplying it from outside does not remove that test — it moves it out of the unit test, into an integration test. The gain is that the business rule becomes testable in a deterministic context.

Summary

  • Testability is a property of the design, not the test; the same business rule can run correctly in two forms, but only one of them leaves a testable lever.
  • Supplying a dependency from outside is not a framework feature but a signature decision: the code expects its dependency to be given to it instead of reaching out for it.
  • In the measurement, both fixtures produced the same record; the internally-creating version’s fixture was ten lines and the externally-supplied version’s was six, and the difference was not just length but that one of them changed global state.
  • An undone global change fails the next test; the test that breaks is not the test that made the mistake.
  • Time, randomness, and similar non-deterministic sources are gathered at a single point that can be supplied from outside; this does not remove the testing of those sources, it moves it to an integration test.

Next Step

In this lesson, the dependencies supplied from outside were small functions that returned a fixed value: clock always gave a thousand, receiptGenerator always gave the same number. This is only one member of a family with four types. A dependency can also be supplied only to fill a parameter’s place, to record calls when it is called, or to be a working but simplified copy of the real behavior. The next lesson writes these types out one by one and separates which question each one answers.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close