Skip to content

Designing for Testability

Bernhard Trinnes edited this page Apr 16, 2026 · 1 revision

Designing for Testability: Legacy and New Code

Testability is not a feature you add after writing code. It is the result of structural decisions made at every class boundary. This page covers how to approach testability differently depending on whether you are starting from scratch or working inside an existing codebase.


New Code: Build the Seam In From the Start

When writing new code, the cost of testability is near zero if you apply three rules from the first line.

Rule 1: Define the interface before the implementation

Start with what the class needs, not what it creates. Write the interface first, then the concrete class, then the tests.

// Step 1: define the contract
class IValveController {
public:
    virtual ~IValveController() = default;
    virtual void open()  = 0;
    virtual void close() = 0;
    virtual bool isOpen() const = 0;
};

// Step 2: implement for production
class HardwareValve : public IValveController { /* ... */ };

// Step 3: implement for tests
class FakeValve : public IValveController { /* ... */ };

If you write HardwareValve first and try to extract an interface later, the interface ends up shaped around the hardware API rather than around the business logic that uses it.

Rule 2: Never use new inside business logic

Every new ConcreteType() inside a class is a hard dependency that cannot be replaced. Pass collaborators in, do not create them.

// ❌ Creates its own dependency, no seam for testing
class PressureMonitor {
    PressureMonitor() : valve_(new HardwareValve()) {}
};

// ✅ Receives its dependency, fully substitutable
class PressureMonitor {
    explicit PressureMonitor(IValveController& valve) : valve_(valve) {}
};

The natural question is: if PressureMonitor no longer creates its collaborators, who does?

The answer is the composition root: the single place in the application, typically main() or a dedicated factory, where all concrete objects are constructed and wired together. Concrete types are only ever created there; everywhere else in the codebase only sees interfaces.

// main.cpp: the composition root
// This is the only place HardwareValve and HardwareSensor are instantiated.

int main()
{
    HardwareValve   valve;    // concrete, created here and nowhere else
    HardwareSensor  sensor;   // concrete, created here and nowhere else

    PressureMonitor monitor(valve);   // receives interface references
    ReadingPipeline pipeline(sensor); // receives interface references

    // ... run application
}

In tests, main() is replaced by the test fixture, which wires in fakes instead:

// In the test fixture: same structure as main(), different concrete types
class PressureMonitorTest : public ::testing::Test {
protected:
    FakeValve       valve;   // fake, no hardware
    PressureMonitor monitor{valve};
};

The business logic classes (PressureMonitor, ReadingPipeline) are identical in both cases. They never know whether they are talking to hardware or a fake.

Rule 3: Keep constructors free of logic

A constructor that does real work (opens a file, initialises hardware, starts a thread) makes every test that instantiates the class pay that cost, or fail entirely if the hardware is absent.

// ❌ Constructor does hardware I/O, test instantiation triggers real hardware
class SensorReader {
    SensorReader() { spi_init(SPI_BUS_1); } // real hardware call
};

// ✅ Hardware initialisation separated into an explicit init step
class SensorReader {
    explicit SensorReader(ISpi& spi) : spi_(spi) {}
    bool init() { return spi_.open(); } // called explicitly, fakeable in tests
};

Legacy Code: Adding Testability Incrementally

Legacy code rarely has interfaces or seams. The goal is not to rewrite everything. It is to introduce testability one class at a time, without breaking existing behaviour.

Step 1: Identify the seam

A seam is a place where you can change behaviour without modifying the code at that point. In C++, the most reliable seam is a virtual function on a base class or interface.

Look for the points where the class under change calls into hardware, the OS, or a vendor library. Those calls are the seam candidates.

// Legacy code: direct hardware call buried in business logic
void DeviceManager::update() {
    int raw = adc_read(ADC_CHANNEL_2); // seam candidate
    processReading(raw);
}

Step 2: Extract an interface around the dependency

Do not change the logic yet. Just wrap the dependency call behind a new interface.

// New interface extracted around the seam
class IAdcReader {
public:
    virtual ~IAdcReader() = default;
    virtual int read(int channel) = 0;
};

// Thin wrapper around the legacy call, production behaviour unchanged
class HardwareAdc : public IAdcReader {
public:
    int read(int channel) override { return adc_read(channel); }
};

Step 3: Inject the interface into the legacy class

Replace the direct call with the injected interface. If the class cannot easily take a constructor parameter (e.g. it is instantiated in many places), use a setter as a transitional step.

class DeviceManager {
public:
    // Setter injection: less disruptive to existing call sites than a constructor change
    void setAdcReader(IAdcReader& adc) { adc_ = &adc; }

    void update() {
        int raw = adc_ ? adc_->read(ADC_CHANNEL_2) : adc_read(ADC_CHANNEL_2);
        processReading(raw);
    }

private:
    IAdcReader* adc_ = nullptr; // null = legacy fallback during transition
};

Once all call sites have been updated, the fallback path is removed and the interface becomes mandatory.

Step 4: Write tests before refactoring further

Once the seam exists, write tests that cover the current behaviour. Do not change the logic before the tests are in place. The tests are your safety net for every subsequent change.

class FakeAdc : public IAdcReader {
public:
    void setReading(int value) { value_ = value; }
    int read(int) override { return value_; }
private:
    int value_ = 0;
};

TEST(DeviceManagerTest, ProcessingUsesValueFromInjectedAdc)
{
    FakeAdc       adc;
    DeviceManager manager;
    manager.setAdcReader(adc);

    adc.setReading(512);
    manager.update();

    // assert on observable outcome of processReading(512)
}

Step 5: Refactor under the safety net

With tests covering the existing behaviour, refactor the internals freely. The tests will catch regressions.


The Strangler Pattern for Large Legacy Classes

When a legacy class is too large to tackle all at once, apply the strangler pattern: extract one responsibility at a time into a new, well-tested class, and delegate to it from the legacy class. Over time the legacy class shrinks until it can be removed.

Before:
┌───────────────────────────────────┐
│  LegacyController                 │
│  - reads ADC                      │
│  - applies calibration            │
│  - controls valve                 │
│  - logs to serial                 │
└───────────────────────────────────┘

After (incremental extraction):
┌───────────────────────────────────┐       ┌──────────────────┐
│  LegacyController (shrinking)     │──────▶│  CalibrationUnit │  new, tested
│  - reads ADC                      │──────▶│  ValveController │  new, tested
│  - logs to serial                 │
└───────────────────────────────────┘

Each extracted class starts with its own interface and its own test file. The legacy class is tested through integration tests until it has been strangled away entirely.


Common Obstacles and How to Resolve Them

"The vendor library has no interface, I cannot wrap it"

Wrap it yourself. Write a thin adapter class that forwards calls to the vendor API. Your business logic never sees the vendor types.

See Module 03: Hardware Dependencies for a worked example.

"The class has too many dependencies to inject them all"

That is a sign the class has too many responsibilities. Split it first. Each resulting class will have fewer, more focused dependencies that are easy to inject.

"Changing the constructor signature breaks too many call sites"

Use setter injection as a transitional step (shown above). Once the injection point is established and tests are in place, migrate call sites to constructor injection incrementally.

"We cannot afford to refactor the legacy code right now"

Then do not refactor it, but do not add new code to it either. Write all new code in separate, testable classes and inject them into the legacy class through the seam. The legacy class becomes a thin shell over time without ever requiring a big-bang rewrite.


Summary

Situation Approach
New code Interface first, inject dependencies, no new in business logic
Legacy code, single seam Extract interface, inject, write tests, then refactor
Legacy code, large class Strangler pattern: extract one responsibility at a time
Vendor library with no interface Write a thin adapter, isolate vendor types to one file
Too many call sites to change Setter injection as a transitional step

See also: Test Architecture Patterns, Module 03: Hardware Dependencies, Module 05: Dependency Injection

Clone this wiki locally