Concepts / Quality, testing and extensibility / Designing for unit tests

Designing for unit tests

Seams, fakes and injected clocks that make a class testable.

01The intuition

The problem it solves

A class sends a reminder if a homework deadline is less than a day away. The first version reads the real clock, talks to the real email server, and builds its own helpers inside, so a test would have to wait for a real deadline and send a real email.

import time, smtplib

class ReminderService:
    def check(self, student: str, deadline: float) -> None:
        if deadline - time.time() < 86400:
            server = smtplib.SMTP("mail.school.example")      # real network
            server.sendmail("teacher", student, "Due tomorrow")
            server.quit()

There is nothing wrong with the logic, and no way to prove it. The class reaches out to the world from inside; a test cannot get between the class and the clock, or between the class and the mail server. An interviewer who asks "how would you test this" is asking whether the design has seams.

Build it one piece at a time

Step one: inject the clock. Take a function that returns the time, defaulting to the real one. The logic is unchanged; the test now controls time.

from typing import Callable

class ReminderService:
    def __init__(self, clock: Callable[[], float] = time.time) -> None:
        self.clock = clock

    def check(self, student: str, deadline: float) -> None:
        if deadline - self.clock() < 86400:
            server = smtplib.SMTP("mail.school.example")
            server.sendmail("teacher", student, "Due tomorrow")
            server.quit()

Step two: inject the collaborator behind an interface. The service depends on something with a send(to, text) method; production passes a real mailer, a test passes a fake that records calls. The class never mentions SMTP again, which also fixes the design, not just the test.

from typing import Protocol

class Mailer(Protocol):
    def send(self, to: str, text: str) -> None: ...

class ReminderService:
    def __init__(self, mailer: Mailer, clock: Callable[[], float] = time.time) -> None:
        self.mailer, self.clock = mailer, clock

    def check(self, student: str, deadline: float) -> None:
        if deadline - self.clock() < 86400:
            self.mailer.send(student, "Due tomorrow")

class FakeMailer:
    def __init__(self) -> None:
        self.sent: list[tuple[str, str]] = []

    def send(self, to: str, text: str) -> None:
        self.sent.append((to, text))

Step three: write the tests that the seams make possible, including the boundary. A fixed clock, a deadline exactly 86,400 seconds away, and an assertion on what the fake recorded. Each test is a sentence about behaviour.

def test_reminds_when_due_within_a_day() -> None:
    mailer = FakeMailer()
    svc = ReminderService(mailer, clock=lambda: 1_000_000.0)
    svc.check("asha", deadline=1_000_000.0 + 3600)
    assert mailer.sent == [("asha", "Due tomorrow")]

def test_no_reminder_at_exactly_one_day() -> None:
    mailer = FakeMailer()
    svc = ReminderService(mailer, clock=lambda: 1_000_000.0)
    svc.check("asha", deadline=1_000_000.0 + 86400)
    assert mailer.sent == []

What each seam lets a test control:

DependencyBeforeAfterTest can now
Timetime.time() insideclock injectedset any instant, hit the exact boundary
Emailsmtplib insideMailer interface injectedassert what was sent, without a network
Randomnessrandom.random() insiderng injected or seededmake a shuffle deterministic

Why it works

The principle is that a class should receive the things it cannot control from outside: time, randomness, the network, the file system, the database. Each injected dependency is a seam where a test can substitute a fake, and the same seam is where production substitutes a different real implementation later. The class's own logic becomes a pure function of its inputs and its collaborators' answers, which is exactly what a unit test can pin down.

Where it breaks: injecting everything makes constructors long and designs abstract for no reason; inject what varies or what reaches outside, and build the rest. Fakes that drift from the real thing give false confidence, so keep one integration test against the real collaborator. And a test that asserts on private fields breaks on every refactor; assert on behaviour through the public API.

a test that must wait for a real deadline takes a day; the same test with an injected clock takes under a millisecond

The trade-off you must say out loud

Testability is paid for in indirection: an interface and a constructor argument where a direct call would do. For a throwaway script that is a bad trade; for a class other people will change, it is the cheapest insurance there is. Say which dependencies you injected and why those. The follow-up that usually comes next is "what would you test first", and the answer is the boundary conditions of the core rule, then the error paths, then the happy path.

Where it shows up in the 100 Google problems

02Used in these problems