Low-level designCore9 min

Testability

Code is testable when a test can control everything it depends on. Pass time, randomness and input and output in instead of reaching for them, and keep the rules in pure functions, and a test can check any case in a millisecond.

Watch it evolve1 / 8
The problem

A pool's entry kiosk

Entry is $4.00 before 4 pm and $6.00 after, and one swimmer in fifty enters free.

80
Start
Stage 1
Stage 2
Final

1 · The situation

A pool's entry kiosk that reads the clock

A public pool sells entry at a kiosk. Before 4 pm entry is off-peak and costs $4.00; from 4 pm it's $6.00. To bring people in, one swimmer in fifty wins free entry. EntryKiosk.priceCents() asks the system clock for the time and a random number generator for the draw:

The first versionJava
class EntryKiosk {
    private final Random random = new Random();

    int priceCents() {
        if (random.nextInt(50) == 0) return 0;
        return LocalTime.now().isBefore(LocalTime.of(16, 0)) ? 400 : 600;
    }
}

Python writes the method price_cents(). The code is short and correct, as far as anyone can tell. Here is the best test it allows:

The whole program, with its only possible checkJava
import java.time.LocalTime;
import java.util.Random;

class EntryKiosk {
    private final Random random = new Random();

    int priceCents() {
        if (random.nextInt(50) == 0) return 0;
        return LocalTime.now().isBefore(LocalTime.of(16, 0)) ? 400 : 600;
    }
}

public class Main {
    public static void main(String[] args) {
        int price = new EntryKiosk().priceCents();
        // The strongest check this code allows: the price is one of the three it could be.
        boolean known = price == 0 || price == 400 || price == 600;
        System.out.println("Price is a valid rate: " + known);
    }
}
Price is a valid rate: true

2 · The change

A rule that changes at 4 pm, and no way to test it

The pool wants off-peak to end at 3:30 pm in winter. Before touching the rule, the team wants tests that pin down today's behavior: 3:59 pm costs $4.00, 4:00 pm costs $6.00, and a winning draw is free. None of them can be written:

The test needs

What the code does instead

The time to be 3:59 pm

Asks the system clock, which says whatever time the test happens to run

The draw to be a win

Asks a random generator, which wins once in fifty runs

The same answer every run

Gives a different answer by the hour, and by luck

So the only check that always passes is the weak one above: the price is one of the three it could be. A test that checks "4:00 pm costs $6.00" would pass in the evening and fail in the morning, which is worse than no test. The rule isn't hard to test because it's complicated; it's hard to test because it reaches out for its inputs.

Cases a test can pin down now
0
Cases the rule has
3

The rule has three cases worth a test: before 4 pm, from 4 pm, and a winning draw. The first version lets a test choose none of them.

3 · The fix

Hand the kiosk its clock and its dice

Start from the first version. The kiosk needs the time and a draw. Instead of fetching them, it should be given them:

EntryKiosk takes a clock and a draw function in its constructor. Java has a type for the clock, java.time.Clock, whose stated purpose is letting other clocks be plugged in; the other languages pass a function that returns the minute of the day.

Java
class EntryKiosk {
    private final Clock clock;
    private final IntSupplier draw;

    EntryKiosk(Clock clock, IntSupplier draw) {
        this.clock = clock;
        this.draw = draw;
    }

    int priceCents() {
        if (draw.getAsInt() == 0) return 0;
        return LocalTime.now(clock).isBefore(LocalTime.of(16, 0)) ? 400 : 600;
    }
}
EntryKiosk− clock: Clock− draw: IntSupplier~ priceCents(): int
The pure rule, the shell, and the testsJava
import java.time.Clock;
import java.time.LocalTime;
import java.util.Random;
import java.util.function.IntSupplier;

final class EntryPrice {
    static int cents(LocalTime time, boolean lucky) {
        if (lucky) return 0;
        return time.isBefore(LocalTime.of(16, 0)) ? 400 : 600;
    }
}

class EntryKiosk {
    private final Clock clock;
    private final IntSupplier draw;

    EntryKiosk(Clock clock, IntSupplier draw) {
        this.clock = clock;
        this.draw = draw;
    }

    int priceCents() {
        return EntryPrice.cents(LocalTime.now(clock), draw.getAsInt() == 0);
    }
}

public class Main {
    static void check(String name, int got, int expected) {
        System.out.println(name + ": " + (got == expected ? "ok" : "FAILED, got " + got));
    }

    public static void main(String[] args) {
        Random random = new Random();
        EntryKiosk real = new EntryKiosk(Clock.systemDefaultZone(), () -> random.nextInt(50));

        check("3:59 pm costs $4.00", EntryPrice.cents(LocalTime.of(15, 59), false), 400);
        check("4:00 pm costs $6.00", EntryPrice.cents(LocalTime.of(16, 0), false), 600);
        check("a winning draw is free", EntryPrice.cents(LocalTime.of(16, 0), true), 0);
    }
}
3:59 pm costs $4.00: ok
4:00 pm costs $6.00: ok
a winning draw is free: ok

All three cases, every run, at any hour.

EntryPrice~ cents(LocalTime, boolean): intEntryKiosk− clock: Clock− draw: IntSupplier~ priceCents(): int

The kiosk depends on the pure rule; the clock and the draw come from outside.

4 · The idea

Seams and pure functions

Code is testable when a test can set up every input, run it, and check the result, quickly and the same way every time. Two tools get you there:

  • A seam is a place where you can change what the code works with without editing the code itself. The kiosk's constructor is one: a test passes a fixed clock, production passes the real one.
  • A pure function returns the same output for the same inputs and does nothing else: no clock, no randomness, no printing, no saving. EntryPrice.cents() is one, so a test just calls it.

Input that's hard to control

The usual seam

The current time

A clock object or a "now" function passed in; or pass the time itself as a value

Random numbers

A draw function or a generator passed in; in tests, one that returns chosen values

Files, network, database

An interface such as a repository, with an in-memory version for tests

Console input and output

Readers and writers passed in, or results returned for the caller to print

Environment variables and configuration

Values passed to the constructor, read once at startup

A common shape follows: a pure core that holds the rules and takes plain values, and a thin shell around it that reads the clock, the dice, the files and the network, then calls the core. The core gets many fast tests; the shell gets a few that check the wiring.

5 · When breaking it is fine

Seams that cost more than they save

Seams aren't free:

  • More parameters. Every constructor that takes a clock is one more thing to pass, and every caller must pass it, or a factory must.
  • Seams nobody uses. Wrapping a string library or a date formatter in an interface adds code without making anything easier to test; plain code is already controllable.
  • Seeds aren't portable. Seeding a random generator makes one language's sequence repeatable, but not across languages or library versions; a draw function that returns chosen values is clearer in a test.

Leave the direct call when the code has no rule worth testing, such as a script that prints today's date, or when a test can control the input another way, such as a temporary folder for a file. Add the seam when you need the first test that can't be written.

6 · Getting it right

Pass values where you can, services where you must

  • Prefer passing the value. If a method needs "now" once, pass the time itself, as the pure function does; pass a clock only when an object reads the time many times.
  • Read the outside world at the edges. Let main, a controller or a scheduler read the clock and the dice, and pass the results inward.

Two more habits keep the seams honest:

  • Wire the real things in one place. One composition root builds the real clock and the real random generator; the rest of the code never names them.
  • Don't test through the seam you control. A test that fakes the clock checks the rule; a separate small test checks the real clock adapter, if there is one.

Language

Seams for time and randomness

Java

java.time.Clock, with Clock.fixed() in tests; a RandomGenerator or a supplier passed in

C++

A std::function or a template parameter for the clock and the draw

JavaScript

Functions passed in, often as default parameters so production callers can omit them

Python

Callables passed in; or a module-level function a test can replace, at the cost of hidden coupling

7 · Look-alikes

Ideas that make code easy to test

Several ideas meet here, each from a different angle.

Idea

Its part in testability

Seam

Where a test can swap what the code works with

Dependency injection

The usual way to create a seam: pass collaborators in

Test doubles

What a test plugs into a seam: stubs, fakes, mocks and spies

Ports and adapters

Seams at the scale of a whole application, around its core

Pure function

Code that needs no seam at all

Global state and singletons

The opposite: a hidden input no test can swap without reaching inside

A static call to the clock is a hidden global, just like a singleton. Both make a function depend on something it doesn't list in its parameters, and both make tests depend on the order and time they run.

8 · In practice

Time at the parking garage

In the parking-lot reference solution, pricing takes a stay's Duration, and a ticket works out the stay from an instant it's given. Both are testable with plain values. Only the lot itself reads the clock, once on entry and once on exit, at the edge of the design.

That's the shape to aim for: rules that take time as a value, and one place that asks what time it is. Design the parking lot, and keep the clock out of pricing.

Check yourself

4 questions

1. The pool moves off-peak to end at 3:30 pm. In the final version, how do you test the new boundary?
2. Why is a test that checks "4:00 pm costs $6.00" against the first version worse than no test?
3. Which is a seam?
4. Should a function that formats cents as dollars get a seam?

Takeaways

Remember this

  • Code is testable when a test can control every input and get the same answer every run.
  • Time, randomness, files, network and configuration are hidden inputs; take them as parameters instead of reaching for them.
  • A seam is a place where a test can swap what the code works with, such as a constructor parameter.
  • Keep rules in pure functions and push the clock, the dice and input and output out to a thin shell.
  • Add a seam when you need a test you can't write, not around code that's already pure.