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

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

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.

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

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

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

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

```js
// 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}`);
});
```

```sh
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.

```js
// 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');
});
```

```sh
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.
