-
Notifications
You must be signed in to change notification settings - Fork 0
Designing for Testability
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.
When writing new code, the cost of testability is near zero if you apply three rules from the first line.
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.
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.
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 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.
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);
}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); }
};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.
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)
}With tests covering the existing behaviour, refactor the internals freely. The tests will catch regressions.
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.
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.
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.
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.
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.
| 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
- Home
- Test Architecture Patterns
- Designing for Testability
- Effective Use of Test Doubles
- Refactoring Toward Testability
- Deterministic Testing
- Testing Processes
- Test Plan Template
- Development Ticket Template
- Black-Box Testing from Doxygen
Module 01 — Code Structure
Module 02 — Unit Tests
Module 03 — Hardware Dependencies
Module 04 — Qt Signals & Slots
Module 05 — Dependency Injection