Creating a Test framework isn’t just for the big players. In fact, the underlying software principles are so simple that you can build your own framework with just a little effort.
Frameworks like GTest, Catch, and others use macros to hide all the magic. This way, you can focus on writing tests without having to deal all the boilerplate code.
With a bit of OOP, C++ templates, a design pattern and just five macros, we can build a unit testing framework that is minimal yet robust against segfaults or unexpected aborts.
Our framework needs to do five basic things in order to successfully run
tests, namely: declare
, define
, register
, run
, and compare
. And
we’ll need five macros to do the same.
Let’s define them one by one.
Declare And Define Tests
Macros
DeclareTest
DefineTest
The DeclareTest
macro creates a singleton
that inherits methods from its parent, the UnitTest
(more on this later).
The DefineTest
macro allows us to define the actual test that is run
when we lauch the program.
The ##
operator, also known as the token pasting operator, allows us
to create a unique class name for each testcase.
To generate a unique class name, either the Module
or the TestName
argument in the DeclareTest
macro must be unique.
Here’s how you’d use it in your project.
The UnitTest
Class
The UnitTest
class is also a singleton and a central part of the
framework. Its primary objective is to manage and run all tests that
are registered with it.
With a protected constructor, test cases can construct their own
copies of the UnitTest
class, and access or inherit its members. But,
there is only one instance of the class that is publicly available. And,
we access it via the getInstance()
method.
The static testList
is used to store pointers to test cases that are
registered with the singleton. This way, we can access the runFunc()
method overriden in test definitions.
The expectEQ()
method compares the actual value returned from the test
to its expected value. And finally, the runTests()
method runs all
registered test cases in a for
loop.
Register, Run, and Evaluate Tests
Now, all that’s remaining is to register, run, and evaluate test cases.
We’ll do this with three simple macros: RegisterTest
, RunTests
, and
the ExpectEQ
macro to make comparisions easy.
Putting It Together: A Sample Test Program
Let’s write a demo test program to see how it all fits together.
Like any other program in C/C++, you have a header file to declare, a source
file to define, and another source file with a main()
function to generate an
executable. Simple. Right?
🔖 How To Stop Tests From Crashing Your Program
Running tests in separate processes saves your program from crashing when one of them throws an exception or aborts. Since each test case runs in its own isolated process, the damage doesn’t spread.
The code below converts the runTests()
method into a multi-process function
with vfork()
(UNIX)
system calls.
In lines 5 and 6, we have defined passed
and failed
as static variables.
This is due to the fact that child processes have their own copies of these
variables. Incrementing them in each child process does not reflect
the total number of tests unless they are defined static.
Now, you can debate whether to use fork()
or vfork()
to create child processes.
I chose vfork()
due to its blocking nature, i.e., the main process waits until
its child process returns, before executing the next instruction. This prints console
logs in the right order.
This is as simple as it can get. A functional yet minimal unit testing framework in C++.
Checkout the complete project on GitHub!