Testable by Design

Testable by Design

Steve Love


@IAmSteveLove@mastodon.social
https://moderncsharp.blogspot.com/
@IAmSteveLove


Outline

  • Tell don’t ask

  • Parameterise from above

  • Some "below the fold" features

  • Managing non-trivial dependencies

  • A word about TDD

  • A design’s language

Buontempo HI cpponsea

45% Discount
Code: CPPONSEA45 (valid from Jun 30 to Sep 30)

Affiliate Link: https://mng.bz/0GEp:

A Heating Controller

  • Turn heating on at certain times every day if needed

  • Facility to read the current temperature

    • via 3rd party API

  • Facility to activate/deactivate heating system

    • a different 3rd party API

Pseudocode

Probably in a loop:

    if start_time < time_now < end_time
        read sensor
        if heating is off
            if temperature < required
                turn on heating
        else
            if temperature > required
                turn off heating

An Unknown API

Any number of reasons -

  • haven’t finalized the hardware

  • haven’t yet decided on a provider/library

  • developed elsewhere in the company

  • 3rd party haven’t finished it yet

  • …​

Uncertainty Drives Design

How do I design so that the choice between A and B is less significant?

Kevlin Henney
— Use Uncertainty As a Driver

Take a Beat

Separate concerns:

Turning the heating on or off
vs.
Deciding when to turn it on or off

The Heating System

Pretend it’s yours

Uncertainty hidden by abstraction

class heating_system
{
public:
    virtual ~heating_system() noexcept = default;

    virtual bool switch_on() = 0;
    virtual bool switch_off() = 0;

    [[nodiscard]] virtual bool current_status() const noexcept = 0;
        // [[nodiscard]] - C++17
};

Rule of 5

heating_system(const heating_system &) = default;
heating_system(heating_system &&) = default;

heating_system& operator=(heating_system &&) = default;
heating_system& operator=(const heating_system &) = default;

or 6…​

protected:
    heating_system() = default;

A Test Double

class fake_heating : public heating::heating_system
{
public:
    explicit fake_heating(bool status) noexcept
        : active { status } {}

    bool switch_on() override { return active = true; }
    bool switch_off() override { return active = false; }

    [[nodiscard]] bool current_status() const noexcept override
        { return active; }

private:
    bool active;
};

The Controller

Take it Literally

class controller
{
public:
    explicit controller(heating::heating_system * other,
                        std::chrono::duration<int> start,
                        std::chrono::duration<int> end);

    void activate() const;

private:
    std::chrono::duration<int> time_on;
    std::chrono::duration<int> time_off;
    heating::heating_system * const heating;
};
void controller::activate() const {
    auto now = chrono::system_clock::now();
    auto base = chrono::floor<chrono::days>(now);   // C++17

    if(now > base + time_on && now <= base + time_off)
        heating->switch_on();
    else
        heating->switch_off();
}

tell the controller to activate

vs.

ask the controller if the time is right

Testing Times

TEST(ControllerTest, Outline)
{
    auto heater = make_unique<fake_heating>(false);

    controller ctrl { heater.get(),
                      8h+30min, 16h+30min };

    ctrl.activate();

    ASSERT_TRUE(heater->current_status());
}

Pass Fail

  1. activate asks for current time

  2. raw pointers make life(time) hard

  3. chrono::duration not really descriptive

Keeping Track of Time

using time_of_day = std::chrono::duration<int>;
using wallclock_time = std::chrono::time_point<std::chrono::system_clock>;

class time_window
{
public:
    time_window(time_of_day start, time_of_day end);

    [[nodiscard]] bool in_window(wallclock_time now) const;

private:
    time_of_day start, end;
};

Sometimes, asking is the right thing to do

A More Testable Controller

class controller
{
public:
    controller(shared_ptr<heating_system> heater, const time_window & timer);

    void activate(wallclock_time now) const;

private:
    shared_ptr<heating_system> heater;
    time_window timer;
};

The Test

auto heater = make_shared<fake_heating>(false);

time_window window { 8h+00min, 18h+00min };
controller ctrl { heater, window };

ctrl.activate(wallclock_time { 10h+11min });
    // or chrono::system_clock::now()

ASSERT_TRUE(heater->current_status());

Test for outcome not implementation

What About Temperature?

controller ctrl { heater, timer };

ctrl.set_required_temp(25);

ctrl.activate(24, wallclock_time { 8h + 15min });

ASSERT_TRUE(heater->current_status());

We just pass in the value to controller

Perhaps there’s a better way…​

The Sensor

A Simple API

extern "C" int sensor_read()

A Condition Filter

filter

A Simple Thermostat

class thermostat : public heating_system
{
public:
    thermostat(shared_ptr<heating_system> heater,
               int target_temp,
               function<int()> read);

    bool switch_on() override;
    bool switch_off() override;

    [[nodiscard]]
    bool current_status() const noexcept override;

private:
    shared_ptr<heating_system> heater;
    int target_temp;
    function<int()> read_sensor;
};
int hardcoded_sensor() { return 22; }
TEST(ThermostatTests, Outline)
{
    auto heater = make_shared<fake_heating>(false);
    auto stat = make_shared<thermostat>(
            heater, 25, hardcoded_sensor);

    stat->switch_on();

    ASSERT_TRUE(heater->current_status());
}

extern "C" int sensor_read()

Update the Controller

int hardcoded_sensor() { return 22; }
auto heater = make_shared<fake_heating>(false);

auto stat = make_shared<thermostat>(
        heater, 25, hardcoded_sensor);

time_window window { 8h+00min, 18h+00min };
controller ctrl { stat, window };

ctrl.activate(wallclock_time { 10h+11min });

ASSERT_TRUE(heater->current_status());

reading the current temperature is encapsulated

A Pattern Emerges

chainofresp

Extending the Idea

template<std::invocable Function>   // concepts - C++20
class timer_activator : public heating_system
{
public:
    timer_activator(std::shared_ptr<heating_system> heater, 
                    const time_window & timer,
                    Function get_now);

    bool switch_on() override;
    bool switch_off() override;

    [[nodiscard]] bool current_status() const noexcept override;

private:
    std::shared_ptr<heating_system> heater;
    time_window timer;
    Function get_now;
};

Replaces controller altogether

Yet Another Test

TEST(ActivatorTests, Chain)
{
    auto heating = make_shared<fake_heating>(false);

    auto get_now = []() { return wallclock_time {8h + 01min }; };
    auto read_sensor = []() { return 22; };

    time_window window { 8h, 9h };

    // CTAD - C++20
    auto timer = shared_ptr<heating_system>{ new timer_activator{ heating, window, get_now } };

    auto stat = shared_ptr<heating_system>{ new therm_activator{ timer, 25, read_sensor } };

    stat->switch_on();

    ASSERT_TRUE(heating->current_status());
}

Each part of the chain calls—​and depends on—​the next

timer and stat are also testable independently

Expanding Further

How would you add functionality to over-ride the heating?

Something like "Extra Hour"

Add a New Activator

template<std::invocable Function>
class override_activator : public heating_system
{
public:
    override_activator(std::shared_ptr<heating_system> heater, Function get_now);

    bool switch_on() override;
    bool switch_off() override;

    void over_ride(wallclock_time off_time);    // <- type-specific

    [[nodiscard]] bool current_status() const noexcept override;
    // ...
};

Chain of Responsibility

chainofresp ext

The Trouble with Templates…​

auto get_now = []() { return wallclock_time { 8h + 01min }; };

auto over =  shared_ptr<override_activator>{ 
    new override_activator{ heating, get_now } };

// ...

// method specific to override_activator
over->over_ride(wallclock_time { 9h+3min + 1h });

Fails to compile

override_activator requires template arguments

Factory Method to the Rescue

Sprinkle a little magic template dust…​

template<template<class> typename T,
                         typename B,
                         std::invocable C>
auto make_activator(std::shared_ptr<B> h, const C & call)
{
    return std::make_shared<T<C>>(std::move(h), call);
}

…​to make using it a little easier

auto over = make_activator<override_activator>(heating, get_now);

// method specific to override_activator
over->over_ride(wallclock_time { 9h+3min + 1h });

Summary

  • Let uncertainty inform design, not block it

  • Own your types and give them names

  • Tell objects what to do rather than ask them how

  • Pass dependencies as arguments instead of wiring them in

  • Extensible design is more than OO

  • …​ and vice-versa!

Thanks!

Steve Love


@IAmSteveLove@mastodon.social
@stevelove.bsky.social
https://moderncsharp.blogspot.com/
https://www.linkedin.com/in/steve-love-1198994

and even
@IAmSteveLove


Aside #1

Performance

std::function vs. templated lambda

With a std::function

class thermostat : public heating_system
{
public:
    thermostat(shared_ptr<heating_system> heater,
               int target_temp,
               function<int()> read);

    bool switch_on() override;
    bool switch_off() override;

    [[nodiscard]]
    bool current_status() const noexcept override;

private:
    shared_ptr<heating_system> heater;
    int target_temp;
    function<int()> read_sensor;
};
bool thermostat::switch_on() {
    if(read_sensor() < target_temp)
        return heater->switch_on();
    else
        return heater->switch_off();
}

With a std::invocable

template<std::invocable Function>
class therm_activator : public heating_system
{
public:
    therm_activator(std::shared_ptr<heating_system> heater,
                    int target,
                    Function read_temp);

    bool switch_on() override;
    bool switch_off() override;

    [[nodiscard]] bool current_status() const noexcept override;

private:
    std::shared_ptr<heating_system> heater;
    int required;
    Function read_sensor;
};
template<std::invocable Function>
bool therm_activator<Function>::switch_on() {
    if(read_sensor() < required)
        return heater->switch_on();
    else
        return heater->switch_off();
}

Benchmark

void BM_ThermostatClass(benchmark::State & state)
{
    auto heating = make_shared<fake_heating>(false);
    auto stat = thermostat {
        heating,
        23,
        []() { return 20; } };

    for(auto _ : state)
    {
        for(auto i = 0ll; i != capacity; ++i)
            benchmark::DoNotOptimize(stat.switch_on());
    }
}
void BM_ThermostatTemplate(benchmark::State & state)
{
    auto heating = make_shared<fake_heating>(false);
    auto stat = heating::therm_activator{
        heating,
        23,
        []() { return 20; } };

    for(auto _ : state)
    {
        for(auto i = 0ll; i != capacity; ++i)
            benchmark::DoNotOptimize(stat.switch_on());
    }
}

Results

capacity = 250LL

g++ (MinGW-W64 x86_64-ucrt-posix-seh, built by Brecht Sanders, r1) 14.1.0

2024-06-01T14:18:11+01:00
Running [...]
Run on (20 X 2995 MHz CPU s)
CPU Caches:
  L1 Data 48 KiB (x10)
  L1 Instruction 32 KiB (x10)
  L2 Unified 1280 KiB (x10)
  L3 Unified 24576 KiB (x1)
----------------------------------------------------------------
Benchmark                      Time             CPU   Iterations
----------------------------------------------------------------
BM_ThermostatClass           469 ns          345 ns      2036364
BM_ThermostatTemplate        211 ns          135 ns      4977778

And the results scale…​.

capacity = 2500LL

----------------------------------------------------------------
Benchmark                      Time             CPU   Iterations
----------------------------------------------------------------
BM_ThermostatClass          4701 ns         1782 ns       640000
BM_ThermostatTemplate       2027 ns          725 ns      1120000

Aside #2

The trouble with RVO…​

An Alternative Activator

class my_activator : public heating_system
{
public:
    explicit my_activator(heating_system & other)
        : ref { other } { }

    [[nodiscard]] bool current_status() const override
        { return ref.current_status(); }

    // ...

private:
    heating_system & ref;
};

Safe…​with caution

void shared_scope()
{
    fake f{ };
    my_activator h{ f };

    cout << h.current_status();

    // all fine - both f and h go
    // out of scope
}

A Very Bad Idea

my_activator make_activator(/*...*/)
{
    fake f{ };
    return my_activator{ f };
}
void run_service()
{
    auto activator = make_activator(/*...*/);

    cout << activator.current_status(); // BANG!
}

A Straw Man

class heating_system
{
public:
    virtual ~heating_system() noexcept = default;
    heating_system() = default;

    heating_system(const heating_system &) = delete;
    heating_system(heating_system &&) = delete;

    heating_system & operator=(const heating_system &) = delete;
    heating_system && operator=(heating_system &&) = delete;

    [[nodiscard]] virtual bool current_status() const = 0;
};

RVO Bites

godbolt rvo