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:
| Dependency | Before | After | Test can now |
|---|---|---|---|
| Time | time.time() inside | clock injected | set any instant, hit the exact boundary |
| smtplib inside | Mailer interface injected | assert what was sent, without a network | |
| Randomness | random.random() inside | rng injected or seeded | make 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
- Stopwatch: a monotonic clock injected is the only way to test pause and resume durations.
- Key-value store with TTL: expiry at the exact instant, tested with a clock the test moves.
- Playlist shuffler: a seeded random source makes the no-repeat rule provable.
- Customer-support dispatcher: injected timestamps make the re-queue order deterministic.
- Tic-tac-toe: a board constructed from a known position, so a win check is one assertion.
02Used in these problems
- Design an LRU cache
- Design a key-value store with TTL
- Design a map with constant-time putAll
- Design a set with O(1) insert, delete and getRandom
- Design a frequency-ordered container with weighted random pick
- Design a peeking iterator
- Design a playlist shuffler with a no-repeat window
- Design a stopwatch class
- Design a thread-safe singleton and justify the alternative
- Design a customer-support agent dispatcher
- Design a multi-elevator dispatcher
- Design a restaurant waitlist
- Design a student-to-room allocation system
- Design tic-tac-toe
- Design the Snake game
- Design 2048
- Design Minesweeper
- Design a traffic signal controller