-
Notifications
You must be signed in to change notification settings - Fork 0
Refactoring Toward Testability
This chapter walks through a single realistic class, FlowController, that starts completely untestable and is refactored in five steps until it has full unit test coverage. Each step is small and safe to apply to production code.
The companion code lives in workshop/06_refactoring/.
FlowController reads a flow sensor, applies a calibration offset, controls a valve, and logs every state change. It is typical embedded legacy code: correct, but structurally impossible to unit test.
// flow_controller_v1.cpp
// ❌ Starting point: everything hard-coded, nothing injectable
#include <cstdio>
#include "vendor/flow_sensor.h"
#include "vendor/valve_driver.h"
static const float CALIBRATION_OFFSET = 0.5f;
static const float FLOW_THRESHOLD = 10.0f;
class FlowController {
public:
FlowController() {
flow_sensor_init(SENSOR_PORT_0); // hardware init in constructor
valve_driver_init(VALVE_PORT_0);
}
void update() {
float raw = flow_sensor_read(SENSOR_PORT_0);
float calibrated = raw + CALIBRATION_OFFSET;
if (calibrated > FLOW_THRESHOLD) {
valve_driver_open(VALVE_PORT_0);
printf("[FlowController] valve opened at %.2f L/min\n", calibrated);
} else {
valve_driver_close(VALVE_PORT_0);
printf("[FlowController] valve closed at %.2f L/min\n", calibrated);
}
}
~FlowController() {
valve_driver_close(VALVE_PORT_0);
flow_sensor_deinit(SENSOR_PORT_0);
}
};Problems in the starting point:
- Hardware initialised in the constructor: instantiating
FlowControllerin a test requires real hardware -
flow_sensor_read()andvalve_driver_open/close()are called directly: no seam exists -
printf()is called directly: log output cannot be captured or suppressed in tests -
CALIBRATION_OFFSETandFLOW_THRESHOLDare compile-time constants: cannot test boundary conditions without changing source code
Identify the three dependencies: the sensor, the valve, and the logger. Define an interface for each without changing any logic yet.
// interfaces.h
// No hardware headers included here
class IFlowSensor {
public:
virtual ~IFlowSensor() = default;
virtual float readLitresPerMinute() = 0;
};
class IValve {
public:
virtual ~IValve() = default;
virtual void open() = 0;
virtual void close() = 0;
};
class ILogger {
public:
virtual ~ILogger() = default;
virtual void log(const char* message) = 0;
};The interfaces describe what FlowController needs, not how any particular hardware provides it. Vendor headers do not appear here.
Wrap each vendor call in a concrete adapter. This is the only file that includes vendor headers.
// hardware_adapters.cpp
#include "interfaces.h"
#include "vendor/flow_sensor.h"
#include "vendor/valve_driver.h"
#include <cstdio>
class HardwareFlowSensor : public IFlowSensor {
public:
float readLitresPerMinute() override {
return flow_sensor_read(SENSOR_PORT_0);
}
};
class HardwareValve : public IValve {
public:
void open() override { valve_driver_open(VALVE_PORT_0); }
void close() override { valve_driver_close(VALVE_PORT_0); }
};
class PrintfLogger : public ILogger {
public:
void log(const char* message) override { printf("%s\n", message); }
};Hardware initialisation and deinitialisation belong in main(), not in these adapters. They are thin wrappers, nothing more.
Replace the direct vendor calls with calls through the injected interfaces. Move hardware init out of the constructor.
// flow_controller_v2.cpp
// ✅ Step 3: constructor injection, no vendor headers
#include "interfaces.h"
#include <cstring>
struct FlowConfig {
float calibrationOffset = 0.5f;
float flowThreshold = 10.0f;
};
class FlowController {
public:
FlowController(IFlowSensor& sensor, IValve& valve,
ILogger& logger, FlowConfig config = {})
: sensor_(sensor), valve_(valve), logger_(logger), config_(config) {}
void update() {
float calibrated = sensor_.readLitresPerMinute() + config_.calibrationOffset;
char msg[64];
if (calibrated > config_.flowThreshold) {
valve_.open();
snprintf(msg, sizeof(msg), "[FlowController] valve opened at %.2f L/min", calibrated);
} else {
valve_.close();
snprintf(msg, sizeof(msg), "[FlowController] valve closed at %.2f L/min", calibrated);
}
logger_.log(msg);
}
private:
IFlowSensor& sensor_;
IValve& valve_;
ILogger& logger_;
FlowConfig config_;
};FlowController now compiles and runs without any hardware present. The composition root in main() provides the concrete adapters.
// flow_controller_test_doubles.h
#include "interfaces.h"
#include <string>
#include <vector>
class StubSensor : public IFlowSensor {
public:
void setReading(float value) { value_ = value; }
float readLitresPerMinute() override { return value_; }
private:
float value_ = 0.0f;
};
class SpyValve : public IValve {
public:
void open() override { ++openCount_; lastAction_ = "open"; }
void close() override { ++closeCount_; lastAction_ = "close"; }
int openCount() const { return openCount_; }
int closeCount() const { return closeCount_; }
const std::string& lastAction() const { return lastAction_; }
private:
int openCount_ = 0;
int closeCount_ = 0;
std::string lastAction_;
};
class SpyLogger : public ILogger {
public:
void log(const char* message) override { messages_.emplace_back(message); }
int callCount() const { return static_cast<int>(messages_.size()); }
const std::string& lastMessage() const { return messages_.back(); }
private:
std::vector<std::string> messages_;
};With the interfaces injected and the test doubles ready, every behaviour of FlowController can be tested without hardware.
// flow_controller_tests.cpp
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "flow_controller_v2.cpp"
#include "flow_controller_test_doubles.h"
using ::testing::HasSubstr;
class FlowControllerTest : public ::testing::Test {
protected:
StubSensor sensor;
SpyValve valve;
SpyLogger logger;
FlowConfig config{.calibrationOffset = 0.5f, .flowThreshold = 10.0f};
FlowController controller{sensor, valve, logger, config};
};
// Valve behaviour
TEST_F(FlowControllerTest, ValveOpensWhenCalibratedFlowExceedsThreshold)
{
sensor.setReading(10.0f); // calibrated = 10.5, above threshold 10.0
controller.update();
EXPECT_EQ(valve.lastAction(), "open");
}
TEST_F(FlowControllerTest, ValveClosesWhenCalibratedFlowIsBelowThreshold)
{
sensor.setReading(9.0f); // calibrated = 9.5, below threshold 10.0
controller.update();
EXPECT_EQ(valve.lastAction(), "close");
}
TEST_F(FlowControllerTest, ValveClosesWhenCalibratedFlowIsExactlyAtThreshold)
{
sensor.setReading(9.5f); // calibrated = 10.0, not above threshold
controller.update();
EXPECT_EQ(valve.lastAction(), "close");
}
// Calibration offset
TEST_F(FlowControllerTest, CalibrationOffsetIsAppliedBeforeThresholdCheck)
{
// Raw reading is below threshold but calibrated value is above
FlowConfig cfg{.calibrationOffset = 2.0f, .flowThreshold = 10.0f};
FlowController c{sensor, valve, logger, cfg};
sensor.setReading(9.0f); // calibrated = 11.0, above threshold
c.update();
EXPECT_EQ(valve.lastAction(), "open");
}
// Logging
TEST_F(FlowControllerTest, UpdateLogsExactlyOnce)
{
sensor.setReading(5.0f);
controller.update();
EXPECT_EQ(logger.callCount(), 1);
}
TEST_F(FlowControllerTest, LogMessageContainsCalibratedFlowValue)
{
sensor.setReading(8.0f); // calibrated = 8.5
controller.update();
EXPECT_THAT(logger.lastMessage(), HasSubstr("8.50"));
}
TEST_F(FlowControllerTest, LogMessageIndicatesValveStateWhenOpen)
{
sensor.setReading(10.5f);
controller.update();
EXPECT_THAT(logger.lastMessage(), HasSubstr("opened"));
}
TEST_F(FlowControllerTest, LogMessageIndicatesValveStateWhenClosed)
{
sensor.setReading(5.0f);
controller.update();
EXPECT_THAT(logger.lastMessage(), HasSubstr("closed"));
}
// Configurable threshold
TEST_F(FlowControllerTest, CustomThresholdIsRespected)
{
FlowConfig cfg{.calibrationOffset = 0.0f, .flowThreshold = 50.0f};
FlowController c{sensor, valve, logger, cfg};
sensor.setReading(30.0f); // below custom threshold of 50
c.update();
EXPECT_EQ(valve.lastAction(), "close");
}| Before | After | Reason |
|---|---|---|
| Vendor headers in business logic | Vendor headers only in hardware_adapters.cpp
|
Isolates hardware dependency to one file |
| Hardware init in constructor | Hardware init in main()
|
Constructor safe to call in tests |
| Direct vendor function calls | Calls through injected interfaces | Seam exists; hardware is replaceable |
printf() directly |
ILogger injected |
Log output can be captured and asserted on |
| Compile-time constants |
FlowConfig struct injected |
Threshold and offset can be varied per test |
// main.cpp
#include "interfaces.h"
#include "hardware_adapters.cpp"
#include "flow_controller_v2.cpp"
int main()
{
flow_sensor_init(SENSOR_PORT_0);
valve_driver_init(VALVE_PORT_0);
HardwareFlowSensor sensor;
HardwareValve valve;
PrintfLogger logger;
FlowController controller(sensor, valve, logger);
while (true) {
controller.update();
usleep(100000); // 100 ms
}
valve_driver_close(VALVE_PORT_0);
flow_sensor_deinit(SENSOR_PORT_0);
}Hardware initialisation is explicit and in one place. FlowController is constructed with real hardware in production and with test doubles in tests. The application behaviour is identical in both cases.
See also: Designing for Testability, Effective Use of Test Doubles, Module 03: Hardware Dependencies
- 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