Modernizing a 25-year-old minimal C++ unit testing framework (Part 2)

chuckallison1 pts0 comments

test-part2

Automated Unit<br>Testing On-The-Cheap: Part 2

C++ Code Capsules

In Part<br>1 of this two-part series I introduced a time-tested (i.e., old :-))<br>technique that handled automated unit testing in a remarkably simple<br>way, including validating proper exception handling. The previous post<br>left two problems on the table, however, and the journey to fix them<br>turns out to be a nice tour of two key features of modern C++:<br>inline variables and modules.

The simplicity of the test framework discussed in Part 1 follows from<br>everything being contained in a small header file, test.h<br>(include guards not shown):

namespace {<br>std::size_t nPass = 0;<br>std::size_t nFail = 0;<br>inline void do_fail(const char* text, const char* fileName, long lineNumber) {<br>std::cout "FAILURE: " text " in file " fileName<br>" on line " lineNumber std::endl;<br>++nFail;<br>inline void do_test(const char* condText, bool cond, const char* fileName, long lineNumber) {<br>if (!cond)<br>do_fail(condText, fileName, lineNumber);<br>else<br>++nPass;<br>inline void succeed_() noexcept {<br>++nPass;<br>inline void report_() {<br>std::cout "\nTest Report:\n\n";<br>std::cout "\tNumber of Passes = " nPass std::endl;<br>std::cout "\tNumber of Failures = " nFail std::endl;

#define test_(cond) do_test(#cond, cond, __FILE__, __LINE__)<br>#define fail_(expr) do_fail(expr, __FILE__, __LINE__)<br>#define throw_(expr,T) \<br>try { \<br>expr; \<br>std::cout "THROW "; \<br>do_fail(#expr,__FILE__,__LINE__); \<br>} catch (const T&) { \<br>++nPass; \<br>} catch (...) { \<br>std::cout "THROW "; \<br>do_fail(#expr,__FILE__,__LINE__); \

#define nothrow_(expr) \<br>try { \<br>expr; \<br>++nPass; \<br>} catch (...) { \<br>std::cout "NOTHROW "; \<br>do_fail(#expr,__FILE__,__LINE__); \

Generally users only have to call the test_ macro, which<br>captures the expression being tested as text along with its associated<br>file name and line number. For example, if a source line is

test_(stk.top() == 1);

then the preprocessor replaces it with the following text in the<br>compilation stream:

do_test("stk.top() == 1", stk.top() == 1, "tstack.cpp", 17);

indicating that the name of the file is tstack.cpp and<br>the call occurred on line 17 of that file. If the test fails then the<br>followed is printed to the console:

FAILURE: stk.top() == 1 in file tstack.cpp on line 17<br>The report_ function prints the number of success and<br>failures, for example:

Test Report:

Number of Passes = 13<br>Number of Failures = 0<br>The other functions exist for completeness but are rarely needed by<br>users.

In this article I will fix the two problems identified at the end of<br>Part 1:

The counters in the anonymous namespace are specific to each<br>individual file. This was by design to avoid global variables and<br>because this framework was meant to be used in single-file student<br>projects, but is an unnecessary constraint; a large project should share<br>the total counts of successes and failures across all project files<br>without violating the<br>One<br>Definition Rule (ODR).

Dependencies on header files have long been recognized as a source<br>of headaches in C++. The macros above call inline functions contained in<br>the anonymous namespace, so each file under test gets it own copy of the<br>code. Modules were introduced in C++20 to alleviate such issues.

It turns out that macros will still be needed here, so I will take a<br>hybrid approach to move as much as possible into a module.

Inline Variables

C++17 introduced the notion of inline<br>variables. Just as with functions, inline<br>variables may be defined in multiple translation units, and the linker<br>is required to collapse all those definitions into one. The rules mirror<br>those for inline functions:

All definitions must be identical (same tokens, same types, same<br>initializer).

The variable has external linkage by default at namespace<br>scope.

It is guaranteed to be the same object across all translation units<br>(same address everywhere).

The fix here is to choose a named namespace and declare<br>nPass and nFail to be inline:

namespace TestFramework {<br>inline std::size_t nPass = 0;<br>inline std::size_t nFail = 0;

// Other functions reside in the same namespace...<br>inline void fail...<br>inline void test...<br>inline void succeed...<br>inline void report...

Since the functions are all in the TestFramework<br>namespace, I have renamed do_fail to fail and<br>do_test to test. I have also removed trailing<br>underscores in the last two functions. Only the macros retain the<br>trailing underscores.

That was easy. The variables satisfy the ODR and don’t pollute the<br>global namespace.

Module Migration

The main motivation for using macros was to capture the expression<br>being tested as a string. I know of no substitute for this, so the<br>macros test_, fail_, throw_, and<br>nothrow_ remain. The only difference is that they will use<br>a fully qualified call to the associated functions in the<br>TestFramework namespace, as in:

#define test_(cond) TestFramework::test(cond, #cond)

To capture the file name and line number I use<br>source_location<br>which came with C++20:

inline void test(<br>bool cond,<br>std::string_view expr,<br>const...

inline test file namespace expr void

Related Articles