In the previous episode, I claimed that our temperature control application is utterly untestable. That’s quite a broad statement, so before going any further, I’d like to clarify what I really meant.
You can always load our code on an embedded device (like a Raspberry Pi) and attach an appropriate physical temperature sensor as well as a real heater. With the help of an additional external thermometer and an additional external heater you can create various conditions and check whether our system behaves as expected. This is called system testing. Even though it’s ultimately necessary, system testing is tedious, time-consuming, error-prone, expensive, depends on hardware and what-have-you. If you discover a bug at this stage, you have to repeat the whole manual procedure again (and again and again).
When I, as a developer, talk about testing, I generally mean unit testing. Unit testing can be done at your desk, on your PC, without external hardware, within seconds. Even better, it can be fully automated and parallelized. So to be more precise: our temperature control app is utterly impossible to unit-test.
What actually is the unit that we want to test? Our focus is on the control logic, so the class “Controller” is our unit-under-test (UUT). We want to verify whether all logic paths are covered and whether they produce the expected output (heater turned on or off).
In the remainder of this post, I’m going to rewrite the code such that unit testing becomes possible. Let’s go!
One major nuisance with the existing code is that it contains an endless loop. It’s impossible to look inside the loop from a unit test-case. So in a first step, I’ll factor out the meat of the loop, the code where the action takes place.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
class Controller { public: ... void run() { while (step()) { ; } } bool step() { int temperature = m_sensor.getTemperature(&m_currentStatus); if (m_currentStatus == ISensor::Status::good) { if (temperature >= m_target) { m_heater.off(); } else if (temperature < m_target - m_hysteresis) { m_heater.on(); } else { // Leave heater as is. } } else { if (m_previousStatus == ISensor::Status::bad) { // Second time in a row bad sensor data. // Give up. m_heater.off(); return false; } } m_previousStatus = m_currentStatus; return true; } ... } |
As you can see, all the loop in “run” now does is invoke a method called “step”. “step” contains the control logic and we have now a much better grip on it. By repeatedly calling “step” from within our test cases we can single step through the code logic and check after every step if we get the desired effects. (Therefore, I like the method name “step”, what about you?)
The fact that we aggregate the sensor and the heater as members of our controller is a gross violation of modern software design principles, as we make our code (the controller) depend on concrete implementations (classes). If we instead depend on interfaces, we can substitute one implementation (a hardware temperature sensor) with a fake/mock temperature sensor (more on mocks later), as long as they both adhere to the interface. So let’s get rid of concrete classes and introduce interfaces:
1 2 3 4 5 6 7 8 |
class ISensor { public: enum class Status { good, bad }; virtual ~ISensor() = default; virtual int getTemperature(Status* status) = 0; }; |
Our familiar hardware sensor implements this interface, just assume that the actual implementation hasn’t changed from the first episode:
1 2 3 4 5 6 |
class Sensor : public ISensor { public: int getTemperature(Status* status) override { /* access hardware */ } }; |
We proceed in a similar fashion with the heater:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class IHeater { public: virtual ~IHeater() = default; virtual void on() = 0; virtual void off() = 0; }; class Heater : public IHeater { public: void on() override { /* access hardware */ } void off() override { /* access hardware */ } }; |
Finally, we can rewrite the controller code such that it works against abstract interfaces instead of concrete implementations:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class Controller { public: Controller(ISensor& sensor, IHeater& heater, int target, int hysteresis) : m_sensor(sensor) , m_heater(heater) , m_target(target) , m_hysteresis(hysteresis) { assert(m_hysteresis >= 0 && m_hysteresis < m_target); } void run() { ... } bool step() { ... } private: ISensor& m_sensor; IHeater& m_heater; ... }; |
All dependencies (sensor, heater) are passed via so-called dependency injection as interfaces to our controller class.
Obviously, in order to work as before, our application still must depend on concrete classes. But only in a single place — the main function:
1 2 3 4 5 6 7 8 |
void main() { Sensor sensor; Heater heater; Controller controller(sensor, heater, 23, 2); controller.run() } |
Our code hasn’t changed a bit in functionality, it behaves just as before. But contrary to the initial design, it’s now utterly testable, er, unit-testable, I mean.
In the next episode, we’ll talk about mocks and finally implement the unit tests. Stay tuned!