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

Apr 27, 2025 - 04:10
 0
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<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.
  1. Agile Software Development, Principles, Patterns, and Practices — Robert C. Martin ↩

  2. Michael C. Feathers ↩

  3. Better known as the Law of Demeter. ↩