Skip to content

Introduction to Test Driven Development

Pavel I. Kryukov edited this page Jul 17, 2018 · 5 revisions

Motivation

There is a small lesson on what TDD is.


Testing simple classes

We are going to look into simple class MIPSRegister, which holds identifier of MIPS register.

Part 1: interfaces

The idea is: instead of writing the class at first, we start from writing a tests on behavior what we expect. Prior to do that, we should understand what interfaces we want from this class. For MIPSRegister they may be:

  1. Deserialization: We expect any number between 0 and 31 to generate a valid object of MIPSRegister class
  2. Special objects: We expect that user can create object to represent HI, LO, HI_LO, RA, Zero, registers
  3. Serialization: We expect that objects may be converted to std::size_t
  4. Check: We expect to check properies of object (is HI, is LO, is RA, is Zero ?)
  5. Print: We expect objects to be outputed according to MIPS ISA

So, synopsys should follow:

class MIPSRegister {
public:
    // Deserializing ctor
    explicit MIPSRegister( uint8 value);

    // Special objects
    static const MIPSRegister mips_hi;
    static const MIPSRegister mips_lo;
    static const MIPSRegister mips_hi_lo;
    static const MIPSRegister ra;
    static const MIPSRegister zero;

    // Serialization
    const size_t to_size_t() const;

    // Check
    bool is_mips_hi() const;
    bool is_mips_lo() const;
    bool is_mips_hi_lo() const;
    bool is_zero() const;
    bool is_ra() const;

    // Output
    friend std::ostream& operator<<( std::ostream& out, const MIPSRegister& rhs);
};

Part 2: Behavior

Now we write what we expect from this class to do. For instance:

  1. We expect any number NOT between 0 and 31 NOT to generate a valid object of MIPSRegister class
  2. We expect that objects generated from numbers do not represent a HI, LO or HI_LO register
  3. We expect that 0 generates Zero register and 31 generates return address
  4. We expect that serialization values of HI, LO, or HI_LO registers are not between 0 and 31 etc.

Such things can be easily expressed with Catch2 environment:

TEST_CASE( "MIPS_registers: Equal")
{
    for ( size_t i = 0; i < 32; ++i)
    {
        CHECK(MIPSRegister(i) == MIPSRegister(i));
        if (i > 0) {
            CHECK(MIPSRegister(i - 1) != MIPSRegister(i));
        }
    }
}

TEST_CASE( "MIPS_registers: Hi_Lo_impossible")
{
    for ( size_t i = 0; i < 32; ++i)
    {
        MIPSRegister reg(i);
        CHECK_FALSE(reg.is_mips_hi());
        CHECK_FALSE(reg.is_mips_lo());
    }
}

TEST_CASE( "MIPS_registers: Zero")
{
    auto reg = MIPSRegister::zero;
    CHECK(reg.is_zero());
    CHECK_FALSE(reg.is_mips_hi());
    CHECK_FALSE(reg.is_mips_lo());
    CHECK(reg.to_size_t() == 0);
}

What are the benefits? We cannot implement something wrong inside class MIPSRegister. Moreover, we may start from a drafty implementation and modify class internals later. For example, we may implement the output function in a silly manner

    friend std::ostream& operator<<( std::ostream& out, const MIPSRegister& rhs)
    {
        switch( rhs.value)
        {
            case 0: return out << "zero";
            case 1: return out << "at";
    // ...
            case 30: return out << "fp";
            case 31: return out << "ra";
            default: return out << "Null";
        }
    }

and then do something more smart, like array generated from define file:

    std::array<std::string_view, MIPSRegister::MAX_REG> MIPSRegister::regTable =
    {{
#define REGISTER(X) # X
#include "mips_register.def"
#undef REGISTER
    }};

    friend std::ostream& operator<<( std::ostream& out, const MIPSRegister& rhs)
    {
        return out << regTable[ rhs.value];
    }

Epilogue

Now we can clone this test to and rename it to RISCVRegister as it should have same interfaces to satisfy RF. But, there are some changes on behavior level:

  1. We expect that no register is HI, LO or HI_LO
  2. We expect that registers generated by mips_hi, mips_lo, and mips_hi_lo are invalid and their usage will lead to error etc.

And after tests are ready, we start to write RISCVRegister implementation.

Testing more complex classes

Let's start from a side note on OOP. Basically, class methods are divided into two groups:

  1. Getters — they return some values, but don't modify class.
auto get(/* args */) const;
  1. Mutators — they return nothing, but modify class
void mutate(/* args */);

For example, let's look at std::queue:

// getters
bool empty() const;
size_t size() const;
const value_type& front() const;
const value_type& back() const;

// mutators
void pop();
void push(const value_type&);
void emplace(/* arguments */);

However, a lot of methods are 'mixed', due to bad design or complexity overhead

In MIPSRegister example we tested getters mostly. How to test mutators? It would be easy if we tested getters already. The idea is to use getters to ensure that mutation happened correctly. For example, we are testing std::queue:

std::queue<int> q;
q.push(2);
CHECK(queue.front() == 2); // We expect to see 2 at front of the queue
CHECK(queue.back() == 2); // We expect to see the same value at back
CHECK_FALSE(queue.empty());
CHECK(queue.size(), 1);
q.push(3)
CHECK(queue.front() == 2); // We still expect to see 2 at front of the queue
CHECK(queue.back() == 3); // We expect to see new value at back
CHECK_FALSE(queue.empty());
CHECK(queue.size(), 2);
q.pop();
CHECK(queue.front() == 3); // There should be 3 at front of the queue
CHECK(queue.back() == 3); // We expect to see 3 at back
CHECK_FALSE(queue.empty());
CHECK(queue.size() == 1);
q.pop();
CHECK(queue.empty()); // Must be empty now
CHECK(queue.size() == 0); // Must have zero size

Testing internal methods of class

Some classes have complicated internal methods which we might want to test prior to testing their interfaces. The good example is RF class: it has internal std::array and accessing methods, then it has read and write methods which operate with MIPSRegister, and externally visible methods communicating with MIPSInstr. MIPS instruction class is already complicated, so our intention is to test RF without it.

How is it possible? There are two ways:

  1. Hacker way. Never do this at home.
#define private public
#include "../rf.h"
#undef private
  1. More safe and elegant way is to move methods you want to test to protected scope and define a following class in unit_test.cpp:
class TestRF : public RF {
public:
    // ctors

    // list of methods you want to use from basic class
    using RF::read;
    using RF::write;
};
Clone this wiki locally