-
Notifications
You must be signed in to change notification settings - Fork 139
Introduction to Test Driven Development
In this lesson we implement class MIPSRegister
, which holds identifier of MIPS register, using Test Driven Development (TDD).
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:
- Deserialization: We expect any number between 0 and 31 to generate a valid object of MIPSRegister class
- Special objects: We expect that user can create object to represent HI, LO, HI_LO, RA, Zero, registers
- Serialization: We expect that objects may be converted to
std::size_t
- Check: We expect to check properies of object (is HI, is LO, is RA, is Zero ?)
- 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);
};
Now we write what we expect from this class to do. For instance:
- We expect any number NOT between 0 and 31 NOT to generate a valid object of MIPSRegister class
- We expect that objects generated from numbers do not represent a HI, LO or HI_LO register
- We expect that 0 generates Zero register and 31 generates return address
- 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];
}
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:
- We expect that no register is HI, LO or HI_LO
- 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.
MIPT-V / MIPT-MIPS — Cycle-accurate pre-silicon simulation.