Software Testing: Theory and Practice (Part 6) - Fundamentals of Design for Testability
Key Takeaways There are several patterns for achieving a design that is easy to test. This article introduces two representative ones: the SOLID principles and extracting functional components. To learn design for testability, Test-Driven Development (TDD) is highly recommended. Overview of Testability In the issue from January 2025 we explained that the heart of shift-left testing is the unit test. Following the guidance of the test pyramid, you end up writing more unit tests than E2E / integration tests. However, unit-test cost is heavily affected by the design of the component under test, much more so than with E2E or integration tests. In a bad example, you might finish the production code in a few hours but spend days writing its unit tests. Consider the following ImageFetcher, which downloads an image. Listing 1 shows a version whose unit tests are expensive for two reasons. Listing 1: An example component with high unit-test cost (TypeScript) class ImageFetcher { // BAD: Uses a global state private static readonly cache = new Map(); async fetchImage(url: string): Promise { if (ImageFetcher.cache.has(url)) { return ImageFetcher.cache.get(url)!; } // BAD: The output of fetch cannot be controlled from test code const response = await fetch(url); if (!response.ok) throw Error(`error: ${response.status}`); const blob = await response.blob(); ImageFetcher.cache.set(url, blob); return blob; } } The first problem is the global cache state. Because the result of fetchImage changes depending on the cache contents, the order in which tests run influences the outcome, making parallel execution difficult. You would have to exclude the ImageFetcher tests from your parallel suite. The second problem is that the fetch response is an indirect input you cannot control. You would need to spin up an HTTP server that returns an image and tell fetch to hit that URL. With just a bit of tweaking, we can slash the unit-test cost (Listing 2). Listing 2: The same code with tweaks that lower test cost class ImageFetcher { // GOOD: cache is now an instance field, not global private readonly cache = new Map(); // GOOD: fetch can be swapped out for a stub constructor(private readonly fetchFunc: (url: string) => Promise) {} async fetchImage(url: string): Promise { if (this.cache.has(url)) { return this.cache.get(url)!; } const response = await this.fetchFunc(url); if (!response.ok) throw Error(`error: ${response.status}`); const blob = await response.blob(); this.cache.set(url, blob); return blob; } } The first tweak moves cache from a global variable to an instance field. If each test creates its own ImageFetcher, execution order no longer matters and you can run the tests in parallel. The second tweak lets us inject a replacement for fetch through the constructor. In tests you can supply a hand-written stub that returns a deterministic response; no need to run an HTTP server. In production you simply pass the real fetch. Changing the production design in this way can dramatically lower unit-test cost. In this series, we will use the term design for testability for designs that keep unit tests cheap to write and maintain. Design for Testability There are principles you should follow to achieve testable designs. Components that adhere to the SOLID principles, for example, are usually easier to test than those that do not. SOLID Principles and Testability SOLID principles is an acronym for five design principles: S – Single Responsibility Principle O – Open/Closed Principle L – Liskov Substitution Principle I – Interface Segregation Principle D – Dependency Inversion Principle Single Responsibility Principle The Single Responsibility Principle states that a component should have exactly one reason to change. When it is violated, multiple separable concerns become tangled together. ImageFetcher in Listing 2 breaks this rule: one might change it either to alter HTTP-status handling or to tweak cache behavior. By splitting it as in Listing 3, you get two components: one interpreting HTTP status codes and one adding caching. Listing 3: Refactoring Listing 2 to satisfy the Single Responsibility Principle // Class that interprets HTTP status codes class ImageFetcher { constructor(private readonly fetchFunc: (url: string) => Promise) {} async fetchImage(url: string): Promise { const response = await this.fetchFunc(url); if (!response.ok) throw Error(`error: ${response.status}`); return response.blob(); } } // Class that adds caching to ImageFetcher class CachedImageFetcher { private readonly cache = new Map(); constructor(private readonly imgFetcher: ImageFetcher) {} async fetchImage(url: string): Promise { if (this.cache.has(url)) { return this.cache.get(url)!; } const blob = await this.imgFetcher.fet

Key Takeaways
- There are several patterns for achieving a design that is easy to test. This article introduces two representative ones: the SOLID principles and extracting functional components.
- To learn design for testability, Test-Driven Development (TDD) is highly recommended.
Overview of Testability
In the issue from January 2025 we explained that the heart of shift-left testing is the unit test.
Following the guidance of the test pyramid, you end up writing more unit tests than E2E / integration tests.
However, unit-test cost is heavily affected by the design of the component under test, much more so than with E2E or integration tests.
In a bad example, you might finish the production code in a few hours but spend days writing its unit tests.
Consider the following ImageFetcher
, which downloads an image.
Listing 1 shows a version whose unit tests are expensive for two reasons.
Listing 1: An example component with high unit-test cost (TypeScript)
class ImageFetcher {
// BAD: Uses a global state
private static readonly cache = new Map<string, Blob>();
async fetchImage(url: string): Promise<Blob> {
if (ImageFetcher.cache.has(url)) {
return ImageFetcher.cache.get(url)!;
}
// BAD: The output of fetch cannot be controlled from test code
const response = await fetch(url);
if (!response.ok) throw Error(`error: ${response.status}`);
const blob = await response.blob();
ImageFetcher.cache.set(url, blob);
return blob;
}
}
The first problem is the global cache
state.
Because the result of fetchImage
changes depending on the cache contents, the order in which tests run influences the outcome, making parallel execution difficult.
You would have to exclude the ImageFetcher
tests from your parallel suite.
The second problem is that the fetch
response is an indirect input you cannot control.
You would need to spin up an HTTP server that returns an image and tell fetch
to hit that URL.
With just a bit of tweaking, we can slash the unit-test cost (Listing 2).
Listing 2: The same code with tweaks that lower test cost
class ImageFetcher {
// GOOD: cache is now an instance field, not global
private readonly cache = new Map<string, Blob>();
// GOOD: fetch can be swapped out for a stub
constructor(private readonly fetchFunc: (url: string) => Promise<Response>) {}
async fetchImage(url: string): Promise<Blob> {
if (this.cache.has(url)) {
return this.cache.get(url)!;
}
const response = await this.fetchFunc(url);
if (!response.ok) throw Error(`error: ${response.status}`);
const blob = await response.blob();
this.cache.set(url, blob);
return blob;
}
}
The first tweak moves cache
from a global variable to an instance field.
If each test creates its own ImageFetcher
, execution order no longer matters and you can run the tests in parallel.
The second tweak lets us inject a replacement for fetch
through the constructor.
In tests you can supply a hand-written stub that returns a deterministic response; no need to run an HTTP server.
In production you simply pass the real fetch
.
Changing the production design in this way can dramatically lower unit-test cost.
In this series, we will use the term design for testability for designs that keep unit tests cheap to write and maintain.
Design for Testability
There are principles you should follow to achieve testable designs.
Components that adhere to the SOLID principles, for example, are usually easier to test than those that do not.
SOLID Principles and Testability
SOLID principles is an acronym for five design principles:
- S – Single Responsibility Principle
- O – Open/Closed Principle
- L – Liskov Substitution Principle
- I – Interface Segregation Principle
- D – Dependency Inversion Principle
Single Responsibility Principle
The Single Responsibility Principle states that a component should have exactly one reason to change.
When it is violated, multiple separable concerns become tangled together.
ImageFetcher
in Listing 2 breaks this rule: one might change it either to alter HTTP-status handling or to tweak cache behavior.
By splitting it as in Listing 3, you get two components: one interpreting HTTP status codes and one adding caching.
Listing 3: Refactoring Listing 2 to satisfy the Single Responsibility Principle
// Class that interprets HTTP status codes
class ImageFetcher {
constructor(private readonly fetchFunc: (url: string) => Promise<Response>) {}
async fetchImage(url: string): Promise<Blob> {
const response = await this.fetchFunc(url);
if (!response.ok) throw Error(`error: ${response.status}`);
return response.blob();
}
}
// Class that adds caching to ImageFetcher
class CachedImageFetcher {
private readonly cache = new Map<string, Blob>();
constructor(private readonly imgFetcher: ImageFetcher) {}
async fetchImage(url: string): Promise<Blob> {
if (this.cache.has(url)) {
return this.cache.get(url)!;
}
const blob = await this.imgFetcher.fetchImage(url);
this.cache.set(url, blob);
return blob;
}
}
When concerns are coupled, you typically need more test cases.
If a function with N branches is combined with one with M branches, covering all paths requires M × N cases, whereas separated components need only M + N.
With M = N = 10, that’s 100 versus 20 — a big difference.
Open/Closed Principle
The Open/Closed Principle says components should be open for extension but closed for modification.
A component that violates it forces you to modify existing tests whenever you extend behavior.
One that obeys it lets you keep the old tests unchanged and add new tests only for the new component, lowering maintenance cost.
ImageFetcher
from Listing 3 can be extended in an open/closed way.
Suppose you need cache entries to expire after a period.
Instead of editing CachedImageFetcher
, add a new ExpirationCachedImageFetcher
(Listing 4).
Listing 4: Extending Listing 3 while honoring the Open/Closed Principle
class ExpirationCachedImageFetcher {
private readonly cache = new Map<string, [number, Blob]>();
constructor(
private readonly imgFetcher: ImageFetcher,
private readonly expirationMs: number
) {}
async fetchImage(url: string): Promise<Blob> {
if (this.cache.has(url)) {
const [deadline, blob] = this.cache.get(url)!;
if (deadline > Date.now()) return blob;
}
const blob = await this.imgFetcher.fetchImage(url);
this.cache.set(url, [Date.now() + this.expirationMs, blob]);
return blob;
}
}
Liskov Substitution Principle
The Liskov Substitution Principle requires that instances of a derived type can replace instances of the base type without altering desirable properties.
When satisfied, you can reuse the base type’s unit tests for the derived type, reducing test-development cost.
Interface Segregation Principle
The Interface Segregation Principle says clients should not be forced to depend on methods they do not use.
Violating it makes you supply test stubs for methods irrelevant to the test, increasing boilerplate and effort.
Dependency Inversion Principle
The Dependency Inversion Principle states that high-level components should not depend on low-level components but both should depend on abstractions.
When violated, components often have indirect inputs you cannot control and indirect outputs you cannot observe from tests, pushing you toward expensive, brittle integration tests.
The earlier ImageFetcher
that called fetch
directly (tagged BAD) violates this principle.
The version that receives fetch
from the outside (tagged GOOD) satisfies it.
Want to Dig Deeper?
We have only skimmed the definitions.
For a thorough treatment, consult Agile Software Development: Principles, Patterns, and Practices 1.
Separating State and Testability
Designing a system so that functional components are separated from stateful ones also improves testability.
A functional component, by definition, returns the same output for the same input.
As noted in Part 1 (October 2024 issue), its behavior can be represented as an input/output table.
Non-functional components are reactive.
Unit-testing reactive components is generally more expensive because you must coerce them into specific internal states.
By isolating stateless functional parts, at least those parts become cheap to test.
Consider FizzBuzzCounter
(Listing 5), which increments a counter and prints the FizzBuzz result:
Listing 5: The FizzBuzzCounter class
// Reactive component
class FizzBuzzCounter {
private mutableCount = 0;
constructor(private readonly printer: Printer) {}
get count(): number {
return this.mutableCount;
}
increment() {
this.mutableCount++;
if (this.count % 3 === 0 && this.count % 5 === 0) {
this.printer.print("FizzBuzz");
} else if (this.count % 3 === 0) {
this.printer.print("Fizz");
} else if (this.count % 5 === 0) {
this.printer.print("Buzz");
} else {
this.printer.print(this.count.toString());
}
}
}
const counter = new FizzBuzzCounter(new Printer());
counter.increment(); // prints 1
counter.increment(); // prints 2
counter.increment(); // prints Fizz
Unit-testing FizzBuzzCounter
is costly: you must call increment
at least 15 times to see "FizzBuzz"
.
You can refactor it into a purely reactive counter, a functional fizzBuzz
function, and a display component (Listing 6).
Only fizzBuzz
is functional, so it is trivial to test.
Listing 6: Decomposing into more testable components
// Reactive component that only counts
class Counter extends EventTarget {
private _count = 0;
get count(): number {
return this._count;
}
increment() {
this._count++;
// Notify listeners of the state change
this.dispatchEvent(new Event("change"));
}
}
// Component that prints FizzBuzz whenever the counter changes
class FizzBuzzDisplay {
private readonly handleChange: () => void;
constructor(private readonly counter: Counter, printer: Printer) {
this.handleChange = () => {
printer.print(fizzBuzz(counter.count));
};
counter.addEventListener("change", this.handleChange);
}
dispose() {
this.counter.removeEventListener("change", this.handleChange);
}
}
// Functional FizzBuzz
function fizzBuzz(count: number): string {
if (count % 3 === 0 && count % 5 === 0) {
return "FizzBuzz";
} else if (count % 3 === 0) {
return "Fizz";
} else if (count % 5 === 0) {
return "Buzz";
} else {
return count.toString();
}
}
const counter = new Counter();
const display = new FizzBuzzDisplay(counter, new Printer());
counter.increment(); // prints 1
counter.increment(); // prints 2
counter.increment(); // prints Fizz
How to Acquire Design-for-Testability Skills
While we have introduced patterns for testable design, applying them consciously in practice is surprisingly hard.
You need deliberate training, and Test-Driven Development (TDD) is an excellent exercise.
TDD is a development process where you write the test before the implementation — the so-called test-first approach — and iterate through refactoring (Figure 1).
Figure 1: Process of TDD
Because tests come first in TDD, you cannot write code that is difficult to test; the process forces you to discover testable designs.
Think of TDD as a brace that keeps you in a design-for-testability posture.
Start with a small codebase.
In a large one, finding a testable design is far more difficult and discouraging.
After you gain confidence, tackle bigger legacy systems — Working Effectively with Legacy Code 2 is invaluable here.
You can tighten the brace further by avoiding mock libraries and DI containers.
Hand-write test doubles as needed and pass dependencies explicitly.
This motivates you to keep components small and simple.
Eschewing DI containers also discourages tests from depending on knowledge of indirect dependencies 3.
When tests rely on such knowledge, implementation details leak into the tests and you risk false positives where tests fail even though the implementation meets the spec.
Treat TDD as training wheels.
Once design-for-testability has become second nature, feel free to take them off and adjust your development speed to the situation.
Summary
- Several patterns help you design code that is easy to test. We covered two major ones: the SOLID principles and extracting functional components.
- To learn design for testability, practice Test-Driven Development.
-
Agile Software Development, Principles, Patterns, and Practices — Robert C. Martin ↩
-
Michael C. Feathers ↩
-
Better known as the Law of Demeter. ↩