C++ Code Capsules
In Part 1 of this two-part series I introduced a time-tested (i.e., old :-)) technique that handled automated unit testing in a remarkably simple way, including validating proper exception handling. The previous post left two problems on the table, however, and the journey to fix them turns out to be a nice tour of two key features of modern C++: inline variables and modules.
The simplicity of the test framework discussed in Part 1 follows from
everything being contained in a small header file, test.h
(include guards not shown):
namespace {
std::size_t nPass = 0;
std::size_t nFail = 0;
inline void do_fail(const char* text, const char* fileName, long lineNumber) {
std::cout << "FAILURE: " << text << " in file " << fileName
<< " on line " << lineNumber << std::endl;
++nFail;
}
inline void do_test(const char* condText, bool cond, const char* fileName, long lineNumber) {
if (!cond)
do_fail(condText, fileName, lineNumber);
else
++nPass;
}
inline void succeed_() noexcept {
++nPass;
}
inline void report_() {
std::cout << "\nTest Report:\n\n";
std::cout << "\tNumber of Passes = " << nPass << std::endl;
std::cout << "\tNumber of Failures = " << nFail << std::endl;
}
}
#define test_(cond) do_test(#cond, cond, __FILE__, __LINE__)
#define fail_(expr) do_fail(expr, __FILE__, __LINE__)
#define throw_(expr,T) \
try { \
expr; \
std::cout << "THROW "; \
do_fail(#expr,__FILE__,__LINE__); \
} catch (const T&) { \
++nPass; \
} catch (...) { \
std::cout << "THROW "; \
do_fail(#expr,__FILE__,__LINE__); \
}
#define nothrow_(expr) \
try { \
expr; \
++nPass; \
} catch (...) { \
std::cout << "NOTHROW "; \
do_fail(#expr,__FILE__,__LINE__); \
} Generally users only have to call the test_ macro, which
captures the expression being tested as text along with its associated
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 compilation stream:
do_test("stk.top() == 1", stk.top() == 1, "tstack.cpp", 17);indicating that the name of the file is tstack.cpp and
the call occurred on line 17 of that file. If the test fails then the
followed is printed to the console:
FAILURE: stk.top() == 1 in file tstack.cpp on line 17
The report_ function prints the number of success and
failures, for example:
Test Report:
Number of Passes = 13
Number of Failures = 0
The other functions exist for completeness but are rarely needed by users.
In this article I will fix the two problems identified at the end of Part 1:
It turns out that macros will still be needed here, so I will take a hybrid approach to move as much as possible into a module.
C++17 introduced the notion of inline
variables. Just as with functions, inline
variables may be defined in multiple translation units, and the linker
is required to collapse all those definitions into one. The rules mirror
those for inline functions:
The fix here is to choose a named namespace and declare
nPass and nFail to be inline:
namespace TestFramework {
inline std::size_t nPass = 0;
inline std::size_t nFail = 0;
// Other functions reside in the same namespace...
inline void fail...
inline void test...
inline void succeed...
inline void report...Since the functions are all in the TestFramework
namespace, I have renamed do_fail to fail and
do_test to test. I have also removed trailing
underscores in the last two functions. Only the macros retain the
trailing underscores.
That was easy. The variables satisfy the ODR and don’t pollute the global namespace.
The main motivation for using macros was to capture the expression
being tested as a string. I know of no substitute for this, so the
macros test_, fail_, throw_, and
nothrow_ remain. The only difference is that they will use
a fully qualified call to the associated functions in the
TestFramework namespace, as in:
#define test_(cond) TestFramework::test(cond, #cond)To capture the file name and line number I use
source_location
which came with C++20:
inline void test(
bool cond,
std::string_view expr,
const std::source_location& loc = std::source_location::current())
{
if (cond)
++nPass;
else
fail(expr, loc);
}Since std::source_location::current() appears as a
default argument, it captures the correct information at the call site.
I also used std::string_view to receive the expression
text.
The question remains on how to encapsulate the functions into a
module and have them interoperate with the macros. Macros are an
artifact of the preprocessor and cannot be exported from a module. The
solution here is to keep the macros in a separate header file, having
them call into the TestFramework namespace, as previously
mentioned:
// test_macros.h: Macro companions to the test module.
// Include this in any test driver that uses test_.
// Macros cannot be exported from modules — this header bridges that gap.
// AUTHOR: Chuck Allison (Creative Commons License, 2001 - 2026)
// This header must appear after `import test;` in client code.
#ifndef TEST_MACROS_H
#define TEST_MACROS_H
#define test_(cond) TestFramework::test(cond, #cond)
#define fail_(msg) TestFramework::fail(msg)
#define throw_(expr, T) \
try { \
expr; \
TestFramework::fail("THROW expected in: " #expr); \
} catch (const T&) { \
++TestFramework::nPass; \
} catch (...) { \
TestFramework::fail("THROW wrong exception: " #expr); \
}
#define nothrow_(expr) \
try { \
expr; \
++TestFramework::nPass; \
} catch (...) { \
TestFramework::fail("NOTHROW expected: " #expr); \
}
#define report_() TestFramework::report()
#endifFollowing current convention, I will define what remains in a module
named test in a file named test.cppm:
// test.cppm: Simple but effective automated test scaffolding.
// C++20 module version using import std and std::source_location.
// Macros live in companion header test_macros.h (modules cannot export macros).
// AUTHOR: Chuck Allison (Creative Commons License, 2001 - 2026)
export module test;
import std;
export namespace TestFramework {
inline std::size_t nPass = 0;
inline std::size_t nFail = 0;
inline void fail(
std::string_view msg,
const std::source_location& loc = std::source_location::current())
{
std::cout << "FAILURE: " << msg
<< " in file " << loc.file_name()
<< " on line " << loc.line()
<< " in function " << loc.function_name() << '\n';
++nFail;
}
inline void test(
bool cond,
std::string_view expr,
const std::source_location& loc = std::source_location::current())
{
if (cond)
++nPass;
else
fail(expr, loc);
}
inline void succeed() {
++nPass;
}
inline void report() {
std::cout << "\nTest Report:\n\n"
<< "\tNumber of Passes = " << nPass << '\n'
<< "\tNumber of Failures = " << nFail << '\n';
}
}Note that the TestFramework namespace must also be
explicitly exported.
To use this module it is necessary to import the test
module before including the test_macros.h header,
so that the TestFramework namespace is visible to the
macros. Here is the Stack test example from Part 1
rewritten to use the test module. (I also made the
Stack class a module.)
// tstack.cpp: Test driver for Stack<T>
// C++20 module version.
import test;
import stack;
import std;
#include "test_macros.h"
int main() {
Stack<int> stk;
test_(stk.size() == 0);
// Test exceptions (top and pop are invalid on empty stack)
throw_(stk.top(), std::underflow_error);
throw_(stk.pop(), std::underflow_error);
nothrow_(stk.size());
// Test push and top
stk.push(1);
test_(stk.top() == 1);
test_(stk.size() == 1);
stk.push(2);
test_(stk.top() == 2);
test_(stk.size() == 2);
// Test pop
stk.pop();
test_(stk.top() == 1);
test_(stk.size() == 1);
stk.pop();
test_(stk.size() == 0);
throw_(stk.top(), std::underflow_error);
throw_(stk.pop(), std::underflow_error);
report_();
}The only difference in the client code is the use of
import and including the header file after the import.
What started as a handful of helper functions in an anonymous namespace now lives in a proper module, sharing state correctly across translation units and reducing header dependencies — all with miniscule changes to test driver code. That’s the kind of upgrade C++20 was designed to make possible. But when it comes to stringizing, macros still earn their keep.
(Note: This code was developed in Visual C++ on Visual Studio 2026 Community Edition. You can download the code here.)