Unit Testing: Principles, Practices, and Patterns
Author: Vladimir Khorikov
Tests and production code are inseparable
A recurring theme in Khorikov’s approach is that you can’t meaningfully improve a test suite without improving the production code it covers. Flaky, brittle, or overly expensive tests are often symptoms of design issues in the underlying codebase—so refactoring test code alone rarely fixes the real problem. Testability is a design property.
Cyclomatic complexity (a quick, useful metric)
Cyclomatic complexity approximates how many execution paths exist in a method or program. In practice, it’s a proxy for “how many branches can happen here?”
Formula: 1 + <number of branching points>
Branching points include things like if/else, switch cases, loop conditions, ternaries, etc. Higher cyclomatic complexity generally means:
more paths to cover,
more edge cases,
higher regression risk,
and often a stronger case for focused unit tests and/or refactoring.
Where unit tests give the highest ROI: the “four types of code” view
Khorikov categorizes code using two axes:
Vertical axis: complexity / domain significance (how important and logic-heavy the code is)
Horizontal axis: number of collaborators (how many external dependencies it touches—DB, network, filesystem, other services)
That gives four quadrants:
Domain model & algorithms (high significance, low collaborators) ✅ Best place for unit tests. These tests are:
high value (protect critical logic),
cheap to maintain (few or no dependencies),
and resistant to refactors.
Overcomplicated code (high significance, high collaborators) ⚠️ Expensive to test and maintain. Often a signal that logic and dependencies are tangled. Refactoring (separating decisions from effects) usually improves both design and testability.
Trivial code (low significance, low collaborators) ❌ Low test ROI. Testing here often adds noise without meaningful protection.
Controllers (low significance, high collaborators) ⚠️ Better suited for integration/contract tests than classic unit tests, because the complexity mostly comes from orchestration and I/O.
Core takeaway:
Unit testing the top-left quadrant (domain model and algorithms) yields the best return—high regression protection at low maintenance cost.
Three styles of unit testing (Chapter-style takeaway)
Khorikov describes three common unit testing styles:
Output-based testing Assert on a function’s returned value (pure input → output). Usually the simplest and most robust form.
State-based testing Assert on the system’s state after execution (e.g., object state, stored data). Useful, but can become brittle if state is spread out or indirectly observable.
Communication-based testing Assert that the system interacted with collaborators in a certain way (mocks/spies, “verify a call happened”). Powerful for orchestration, but often the most fragile if overused—because it couples tests to implementation details.
Referential transparency (why pure logic is so testable)
Referential transparency means:
you can replace a function call with its result value without changing program behavior.
That’s basically the “pure function” superpower—and it’s why output-based tests tend to be cheap and stable.
Functional architecture: separate decisions from side effects
Khorikov leans on a functional programming idea:
Functional programming isn’t about eliminating side effects entirely—it’s about isolating them.
Goal: keep business logic clean and testable by separating it from I/O and mutation.
This separation creates two roles:
Code that makes decisions Pure logic. No side effects. Can often be written like math.
Code that acts on decisions Applies those decisions to the real world: DB writes, events, HTTP calls, messaging, etc.
This leads to a useful architecture metaphor:
Functional core (immutable core): decision-making, pure business logic
Mutable shell: side effects and integration details pushed to the edges
This directly supports the earlier testing guidance: your functional core lives in the “high value / low collaborators” quadrant—perfect for unit tests.
A quote worth keeping (Michael Feathers)
“Object-oriented programming makes code understandable by encapsulating moving parts. Functional programming makes code understandable by minimizing moving parts.”
This pairs nicely with the book’s theme: tests become cheaper and more valuable when the code has fewer moving parts and fewer collaborators.