Open this lesson in your favourite AI. It'll walk you through the why, explain the demo, and quiz you on the try-it list.
Untestable code is a design problem, not a testing problem. When a class creates its own dependencies with new MyDatabase(), calls datetime.now() directly, or uses global singletons, it becomes impossible to test in isolation — you can't replace the real database with a fake, or freeze time to test a time-sensitive function. Testable code is designed with seams: points where behavior can be changed without modifying the source code (injected dependencies, passed-in clocks, injected configuration). Ironically, testable code is almost always better-designed code: it's more modular, has clearer interfaces, and is easier to maintain — testing is just the pressure that forces good design.
Untestable code is a design problem masquerading as a testing problem: when a function calls datetime.now() directly, instantiates its own database connection, or reads from global state, there is no way to change its behavior in a test without modifying the source. Introducing a seam — a point where behavior can be swapped without changing production code — is the refactoring move that converts untestable code to testable code, typically by accepting the dependency as a parameter with a sensible production default. Seam-based design is also better design: it makes dependencies explicit, reduces coupling, and makes the code easier to reason about.
from datetime import datetime, timedelta
from typing import Callable, Optional
# ✗ UNTESTABLE: hardcoded datetime.now(), direct DB import ──────────────────────
# class SessionManager:
# def create_session(self, user_id: int) -> dict:
# from database import db # untestable global
# expiry = datetime.now() + timedelta(hours=24) # untestable clock
# session = db.sessions.insert({"user_id": user_id, "expires": expiry})
# return session
# ✓ TESTABLE: inject the clock and repository ─────────────────────────────────
class SessionRepo:
"""Interface (Protocol in real code) — tells us what the DB does."""
def insert(self, session: dict) -> dict: ...
def find_by_token(self, token: str) -> Optional[dict]: ...
def delete(self, token: str) -> None: ...
class SessionManager:
def __init__(
self,
repo: SessionRepo,
clock: Callable[[], datetime] = datetime.now, # clock injection
ttl_hours: int = 24,
):
self._repo = repo
self._clock = clock
self._ttl = ttl_hours
def create_session(self, user_id: int) -> dict:
import secrets
now = self._clock() # use injected clock, not datetime.now()
expiry = now + timedelta(hours=self._ttl)
return self._repo.insert({
"user_id": user_id,
"token": secrets.token_hex(32),
"expires": expiry.isoformat(),
})
def is_valid(self, token: str) -> bool:
session = self._repo.find_by_token(token)
if not session: return False
return datetime.fromisoformat(session["expires"]) > self._clock()
# ── Tests: clock is frozen, repo is a fake ───────────────────────────────────
class FakeSessionRepo:
def __init__(self): self._store = {}
def insert(self, s):
s["id"] = len(self._store) + 1
self._store[s["token"]] = s
return s
def find_by_token(self, token): return self._store.get(token)
def delete(self, token): self._store.pop(token, None)
FIXED_NOW = datetime(2026, 1, 1, 12, 0, 0)
def frozen_clock(): return FIXED_NOW
def test_session_expires_after_ttl():
repo = FakeSessionRepo()
mgr = SessionManager(repo, clock=frozen_clock, ttl_hours=1)
sess = mgr.create_session(user_id=42)
# Advance clock past expiry
past_expiry = lambda: FIXED_NOW + timedelta(hours=2)
mgr_later = SessionManager(repo, clock=past_expiry)
assert mgr_later.is_valid(sess["token"]) is False
def test_session_valid_before_expiry():
repo = FakeSessionRepo()
mgr = SessionManager(repo, clock=frozen_clock, ttl_hours=24)
sess = mgr.create_session(user_id=1)
assert mgr.is_valid(sess["token"]) is Truepython3 main.pydatetime.now() or time.time() directly. Refactor it to accept a clock parameter (defaulting to datetime.now). Write tests that freeze time by passing a lambda that returns a fixed datetime.self.db = PostgresDB()). Refactor to accept the dependency in __init__. Write a test using a fake instead of the real DB. How did the test change?PureFunctionExample module: 5 functions with no side effects, no I/O, no global state. Note that each is trivially testable with just input/output assertions. Compare to 5 functions in your codebase that have side effects — what makes each one harder to test?Use these three in order. Each builds on the one before.
In one paragraph, explain what a 'seam' is in testable code. Give a concrete example of a seam created by constructor injection, and explain why it makes testing possible.
Walk me through the refactoring steps to make untestable code testable: identify the hidden dependency (global, new, datetime.now), decide how to inject it (constructor, function parameter, module-level override), write the test using the injected version, and verify behavior is unchanged in production (default value handles the real dependency).
I've been asked to add tests to a module that has zero tests and is deeply coupled to a database, a third-party payment API, and several global configuration objects. I can't refactor everything at once. Walk me through the 'strangler fig' approach to gradually adding testability: how to identify the first seam to introduce, how to add a thin abstraction layer, how to write the first test without full refactoring, and how to measure progress over 4 sprints.