All chapters in this track
- 01Chapter 1: Why Coding Standards Matter: Readability, Onboarding, Defects, Consistency at Scale
- 02Chapter 2: Naming Conventions
- 03Chapter 3: Formatting and Style
- 04Chapter 4: Comments and Self-Documenting Code
- 05Chapter 5: Error Handling Conventions
- 06Chapter 6: Code and Project Organization (Files, Modules, Packages)
- 07Chapter 7: Writing Testable Code
- 08Chapter 8: Commit Hygiene: Atomic Commits, Messages, PR-Sized Changes
- 09Chapter 9: Language-Specific Idioms and Anti-Patterns Across TypeScript and Python
- 10Chapter 10: Security-Conscious Basics: Input Validation, Secrets Handling, Injection Risks
- 11Chapter 11: Enforcing Standards Automatically: Linters, Formatters, CI Gates
- 12Chapter 12: Creating, Documenting, and Evolving a Team Style Guide (Exceptions and Legacy Code)
- 13Chapter 13: Worked Example: Refactoring a Messy Function to Standard
- 14Chapter 14: Worked Example: Drafting a Style Guide Section From Scratch
- 15Chapter 15: Common Interview Questions About Coding Standards and Code Quality
Chapter 7 of 15
Chapter 7: Writing Testable Code
Why this matters
Testability isn't a separate concern from good design — code that's hard to test is almost always hard to test because it has an underlying design problem (hidden dependencies, mixed concerns, hard-coded external state), and the same fixes that make code testable also make it more maintainable generally. This chapter treats "is this easy to test?" as a genuinely useful design-quality signal, not just a QA checkbox.
Hidden dependencies: the root cause of most untestable code
[BEGINNER] Code that constructs its own dependencies internally (rather than receiving them from outside) is hard to test because a test has no way to substitute a controlled, predictable test double for the real dependency.
Before — a hidden dependency makes this untestable without hitting a real database and a real clock:
class OrderService {
placeOrder(items: Item[]): Order {
const db = new PostgresConnection(); // hard-wired -- a test can't substitute this
const total = items.reduce((sum, item) => sum + item.price, 0);
const order = { items, total, createdAt: new Date() }; // real system clock -- untestable timing
db.save(order);
return order;
}
}
After — dependencies are injected, making every part substitutable in a test:
interface Database {
save(order: Order): void;
}
interface Clock {
now(): Date;
}
class OrderService {
constructor(private db: Database, private clock: Clock) {} // injected -- DIP, OOP book Chapter 10
placeOrder(items: Item[]): Order {
const total = items.reduce((sum, item) => sum + item.price, 0);
const order = { items, total, createdAt: this.clock.now() };
this.db.save(order);
return order;
}
}
// In a test: substitute controlled, predictable fakes -- no real database, no real clock:
const fakeDb: Database = { save: jest.fn() };
const fixedClock: Clock = { now: () => new Date("2026-01-01T00:00:00Z") };
const service = new OrderService(fakeDb, fixedClock);
const order = service.placeOrder([{ price: 1000 }]);
expect(order.createdAt).toEqual(new Date("2026-01-01T00:00:00Z")); // deterministic, repeatable
from datetime import datetime
from typing import Protocol
class Database(Protocol):
def save(self, order: dict) -> None: ...
class Clock(Protocol):
def now(self) -> datetime: ...
class OrderService:
def __init__(self, db: Database, clock: Clock) -> None:
self._db = db
self._clock = clock
def place_order(self, items: list[dict]) -> dict:
total = sum(item["price"] for item in items)
order = {"items": items, "total": total, "created_at": self._clock.now()}
self._db.save(order)
return order
[IN PRACTICE] This is precisely the OOP book's Chapter 10 Dependency Inversion Principle, and the direct connection between "well-designed" and "testable" is the whole point of this chapter: code designed with DIP in mind is, almost automatically, easy to test, because the exact same mechanism (depending on an abstraction, injected from outside) that decouples high-level logic from low-level details also happens to be exactly what a test needs to substitute controlled fakes.
Separating pure logic from side effects
[SENIOR INSIGHT] A pure function (same input always produces the same output, no side effects) is trivially testable — no setup, no mocking, no cleanup, just call it and check the result. Code that mixes pure computation with side effects (I/O, mutation of external state) forces every test of that mixed logic to also deal with the side effects, even when the test's actual interest is only in the computation.
# Mixed: computation and I/O tangled together -- testing the DISCOUNT LOGIC
# requires mocking a database call, even though the logic itself has nothing
# to do with databases.
def calculate_final_price(order_id):
order = db.get_order(order_id) # I/O
discount = 0.1 if order.total > 100 else 0
return order.total * (1 - discount) # the actual logic worth testing directly
# Separated: pure logic extracted, testable with zero mocking at all
def calculate_discount(total: float) -> float:
return 0.1 if total > 100 else 0
def calculate_final_price_pure(total: float) -> float:
return total * (1 - calculate_discount(total))
# The I/O-touching function becomes a thin wrapper around the pure logic:
def calculate_final_price(order_id):
order = db.get_order(order_id)
return calculate_final_price_pure(order.total)
# Testing the actual business logic now requires NOTHING but a plain function call:
assert calculate_final_price_pure(150) == 135.0
This pattern — sometimes called "functional core, imperative shell" — pushes as much logic as possible into pure, easily-testable functions, leaving only a thin outer layer responsible for I/O and side effects, which typically needs far less exhaustive testing since it contains little to no actual logic to get wrong.
Avoiding global/static mutable state
[BEGINNER] Global mutable state (a module-level variable, a singleton with mutable internal state — the OOP book's Chapter 4 Singleton discussion applies directly) makes tests order-dependent and non-isolated: one test's mutation of global state can silently affect a completely unrelated test that happens to run afterward, producing intermittent, confusing test failures that depend on execution order.
// Global mutable state -- tests can interfere with each other silently
let requestCount = 0;
function trackRequest() { requestCount++; }
// Test A increments requestCount. Test B, run afterward, sees a "polluted"
// starting value it didn't expect -- and whether this causes a failure
// depends entirely on TEST EXECUTION ORDER, which is exactly the kind of
// hard-to-reproduce bug concurrency taught us to recognize (companion
// Concurrency book, Chapter 11) -- test isolation failures share the same
// "intermittent, order-dependent" signature.
The fix: encapsulate state in an instance (created fresh per test) rather than a module-level global, exactly the OOP book's Chapter 3 encapsulation principle, applied to test isolation specifically.
Common mistakes
- Constructing dependencies internally rather than injecting them, making it impossible for a test to substitute a controlled fake without extensive mocking-framework gymnastics or, worse, requiring a real database/network connection just to run a unit test.
- Mixing pure computation with I/O/side effects in the same function, forcing every test of the logic to also deal with mocking the side effects.
- Relying on global or static mutable state, causing tests to silently interfere with each other depending on execution order — a subtle, intermittent test-suite failure mode that's genuinely hard to diagnose.
- Treating "this code is hard to test" as a testing problem to work around (with increasingly elaborate mocking) rather than a design signal pointing at a hidden dependency, mixed concern, or global state problem worth fixing directly.
Checklist: writing testable code
- Inject dependencies (databases, clocks, network clients, file systems) rather than constructing them internally — this is Dependency Inversion, and it's simultaneously good design and the direct enabler of substitutable test doubles.
- Extract pure logic away from I/O and side effects wherever possible — a "functional core, imperative shell" structure minimizes how much of the codebase needs mocking-heavy tests at all.
- Avoid global/static mutable state; encapsulate state in instances created fresh per test (or per request) instead.
- When code is hard to test, treat that difficulty as a design smell — identify the specific hidden dependency, mixed concern, or global state causing the difficulty, and fix the design rather than reaching for heavier mocking tools.
Discussion / interview questions
-
"Why is code with hidden, internally-constructed dependencies hard to test?" Strong answer: A test has no way to substitute a controlled, predictable fake for a dependency the code constructs itself internally — testing such code either requires heavy mocking-framework tricks or actually exercising the real dependency (a real database, a real clock), which is slow, flaky, and hard to make deterministic.
-
"What is 'functional core, imperative shell,' and why does it help testability?" Strong answer: Separating pure computational logic (no side effects, trivially testable) from a thin outer layer handling I/O and side effects — this minimizes how much of the codebase requires mocking-heavy tests, since the bulk of actual logic can be tested with plain function calls and no setup.
-
"How can global mutable state cause intermittent, hard-to-diagnose test failures?" Strong answer: One test's mutation of shared global state can silently affect a different, unrelated test that runs afterward, causing failures that depend on test execution order — a subtle, hard-to-reproduce failure mode, fixed by encapsulating state in instances created fresh per test rather than relying on module-level globals.
-
"If a piece of code is hard to test, what would you consider first: writing more elaborate mocks, or something else?" Strong answer: Consider the difficulty a design signal first — check for hidden, internally-constructed dependencies, mixed pure-logic-and-side-effects, or global mutable state — and fix the underlying design (dependency injection, separating logic from I/O) rather than reaching immediately for heavier mocking machinery to work around it.
Chapter summary / cheat sheet
- Hidden, internally-constructed dependencies are the root cause of most untestable code — inject dependencies instead (Dependency Inversion), which is simultaneously good design and directly enables test substitution.
- Separate pure logic from I/O/side effects ("functional core, imperative shell") — pure functions need no mocking at all and are trivially testable.
- Avoid global/static mutable state — it causes silent, order-dependent test interference; encapsulate state in fresh instances instead.
- "This code is hard to test" is a design smell, not just a testing inconvenience — fix the underlying design issue rather than reaching for heavier mocking tools.