Design for Testability

Learning Objectives

Design classes with small interfaces, explicit dependencies and deterministic behaviour.

Isolate I/O and global state; abstract clocks and randomness when useful.

Prerequisites: Modules 22, 64.

Intuition and Definition

Testability is how easily you can put an object into a known state, act on it and observe the result. The main enemies: hidden dependencies (singletons, statics, new of infrastructure inside logic), non-determinism (time, randomness, threads), and side effects mixed with decisions.

Inject dependencies through constructors.

Separate decisions (pure logic) from effects (I/O).

Abstract the clock and random source *when behaviour depends on them*.

Keep interfaces small so fakes are trivial.

Avoid global mutable state.

Why It Matters

Testable design is also good design: the same properties make code reusable, understandable and changeable.

Syntax and Language Notes

// .NET 8+: TimeProvider is the standard injectable clock
public sealed class TokenValidator(TimeProvider clock) { public bool Expired(DateTimeOffset exp) => clock.GetUtcNow() > exp; }

Complete Example

#include <functional>
#include <iostream>
#include <random>
#include <string>

// hard to test: reads the real clock and a global RNG inside the logic
// bool shouldSendHeartbeat() { return time(nullptr) % 60 == 0 || rand() % 100 < 5; }

struct Clock { virtual ~Clock() = default; virtual long nowSeconds() const = 0; };
struct Random { virtual ~Random() = default; virtual int percent() = 0; };

class HeartbeatPolicy {                                     // decisions only, dependencies injected
    const Clock& clock_;
    Random& rng_;
    long lastSent_ = -1000;
public:
    HeartbeatPolicy(const Clock& c, Random& r) : clock_(c), rng_(r) {}
    bool due() {
        long now = clock_.nowSeconds();
        bool periodic = now - lastSent_ >= 60;
        bool jitter = rng_.percent() < 5;                   // occasional extra heartbeat
        if (periodic || jitter) { lastSent_ = now; return true; }
        return false;
    }
};

// production implementations
struct SeededRandom : Random { std::mt19937 gen{42}; int percent() override { return static_cast<int>(gen() % 100); } };
// test doubles
struct FixedClock : Clock { long t = 0; long nowSeconds() const override { return t; } };
str
…
periodic only: 0:Y 30:n 59:n 60:Y 61:n 120:Y 
jitter forces send: 1, again 1 s later: 1
seeded run over 60 s sent 1 heartbeats

Step-by-Step Walkthrough

The commented "hard to test" version can only be tested by waiting for real time and hoping for random numbers.

With injected Clock and Random, the test drives time explicitly (0, 30, 59, 60, …) and removes randomness (NeverRandom), so the periodic rule is checked exactly: sends at 0, 60 and 120.

AlwaysRandom exercises the jitter branch deterministically.

A seeded generator gives reproducible behaviour even in an end-to-end run.

Common Mistakes

Abstracting everything (wrapping std::string, math functions) — only abstract what makes behaviour non-deterministic or slow.

Test-only public methods that expose internals — expose behaviour, inject dependencies instead.

Static/global state reset between tests "by convention".

Hidden I/O in constructors (opening files or connections) — move it to factories or explicit open() calls.

Trade-offs and Alternatives

Every abstraction is a cost; introduce seams where tests need them. Alternatives for legacy code: link-time substitution, preprocessor seams, or wrapping a global in a thin injectable interface.

Real-World Use

.NET TimeProvider, Java Clock, Python freezegun/time-machine, embedded HALs that can be replaced by simulators, game engines with fixed-timestep simulation for deterministic replays.

Practice

Q1. (Easy) Name two sources of non-determinism to abstract.

Answer: Time (clock) and randomness (also network, file system, thread scheduling).

Q2. (Medium) Why is I/O in constructors bad for testability?

Answer: You can't create the object in a test without real files/connections, and failures surface as constructor exceptions.

Q3. (Advanced) How do you test code that calls a static function you can't change?

Answer: Wrap the static call behind an interface (adapter) used by your code, inject the adapter; in tests inject a fake. For legacy code, a link seam or a function pointer defaulting to the real function also works.

Interview Question

Q1. "How do you test time-dependent business logic, like token expiry or opening hours?"

Answer: Inject a clock abstraction (TimeProvider, Clock, a now function) into the class, never calling the system clock directly in the logic; in tests use a fake clock you can set and advance; test boundary instants (exactly at expiry, one tick before/after) and time-zone/DST edges; keep the real clock adapter trivial.

Quick Quiz

Q1. What is a seam?

Answer: A place where you can substitute behaviour without editing the code there.

Q2. Should every class depend on interfaces?

Answer: No — only where substitution is needed (I/O, time, randomness, external systems).

Q3. How do you make random behaviour reproducible?

Answer: Inject the random source and seed it in tests.

Summary

Testable design: explicit dependencies, deterministic logic, isolated I/O, small interfaces, no global state.

Abstract time and randomness when behaviour depends on them.

Related: Modules 22, 59, 64–66.