Skip to content

Refactoring Toward Testability

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

Refactoring Toward Testability: Hands-On Examples

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/.


The Starting Point

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 FlowController in a test requires real hardware
  • flow_sensor_read() and valve_driver_open/close() are called directly: no seam exists
  • printf() is called directly: log output cannot be captured or suppressed in tests
  • CALIBRATION_OFFSET and FLOW_THRESHOLD are compile-time constants: cannot test boundary conditions without changing source code

Step 1: Extract Interfaces

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.


Step 2: Write the Concrete Adapters

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.


Step 3: Inject Dependencies into FlowController

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.


Step 4: Write the Test Doubles

// 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_;
};

Step 5: Write the Tests

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");
}

What Changed and Why

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

The Composition Root After Refactoring

// 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

Clone this wiki locally