25年C++单元测试框架现代化改造(二)
速览
本文是系列第二部分,继续讨论如何对一款已有25年历史的最小化C++单元测试框架进行现代化改造。内容涉及代码结构优化、兼容性维护以及现代C++特性(如模板、lambda)的应用。有助于理解C++测试框架的演变与最佳实践。
AI 深度解读
背景
本系列文章的第二部分延续了 Part 1 中介绍的 C++ 单元测试框架。该框架最初设计于 20 世纪 90 年代末,采用极简方式实现自动化测试,包括异常验证。Part 1 指出了两个遗留问题:
-
计数器文件局部化:匿名命名空间(
namespace { ... })中的nPass和nFail计数器在每个翻译单元中独立。这一设计最初是为了避免全局变量,且框架初衷是用于单文件学生项目;但在大型项目中,这种限制不必要——项目应能跨所有文件共享总通过/失败计数,同时不违反单一定义规则(ODR)。 -
头文件依赖与宏耦合:传统的头文件依赖是 C++ 长期以来的痛点。Part 1 的宏调用了匿名命名空间中的内联函数,导致每个被测文件都获得函数的一份独立拷贝。C++20 引入模块(modules)正是为了缓解此类问题。
核心内容
原始框架回顾
Part 1 的测试框架将一切封装在一个小型头文件 test.h 中(省略了 include guards):
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__); \
}
用户通常只需调用 test_ 宏,该宏可以捕获被测试表达式的文本、文件名和行号。例如 test_(stk.top() == 1); 会被预处理为 do_test("stk.top() == 1", stk.top() == 1, "tstack.cpp", 17);。若测试失败,则输出 FAILURE: stk.top() == 1 in file tstack.cpp on line 17。report_ 函数则输出通过次数和失败次数。
第一个问题的解决:inline 变量与命名空间
C++17 引入了 inline 变量(inline variables)。与内联函数类似,inline 变量可以在多个翻译单元中定义,链接器会将所有定义折叠为同一个实体。规则与内联函数相同:
- 所有定义必须完全一致(相同 token、相同类型、相同初始化器)。
- 在命名空间作用域下具有外部链接。
- 保证在所有翻译单元中是同一个对象(地址相同)。
修正方案是选择一个具名命名空间,并将 nPass 和 nFail 声明为 inline:
namespace TestFramework {
inline std::size_t nPass = 0;
inline std::size_t nFail = 0;
// 其他函数也放在同一命名空间中
inline void fail(...); // 原 do_fail
inline void test(...); // 原 do_test
inline void succeed();
inline void report();
}
函数名去掉了 do_ 前缀和末尾的下划线,仅宏保留末尾下划线。宏需要调用完全限定名称,例如:
#define test_(cond) TestFramework::test(#cond, cond, __FILE__, __LINE__)
这样计数器满足 ODR,且不污染全局命名空间。如果想保持头文件-only 的风格,这是一个很好的方案。
第二个问题的解决:模块化
模块是打包功能的更干净、更现代的方式。事实上,既然 C++20 有了模块,inline 变量的需求就大大降低了。可以将变量和函数移入自己的模块。但在迁移前,需要决定宏的处理方式,因为宏是预处理器的产物,无法从模块中导出。
宏仍然需要,原因如下:
test_宏的核心价值在于将表达式捕获为字符串(#cond)——没有其他替代方式。- 但对于文件名和行号,可以使用 C++20 引入的
std::source_location:
void test(
bool cond,
std::string_view expr,
const std::source_location& loc = std::source_location::current())
{
if (cond)
++nPass;
else
fail(expr, loc);
}
由于 std::source_location::current() 作为默认参数出现,它会正确捕获调用点的信息(文件名、行号、函数名)。同时也改用 std::string_view 接收表达式文本。
宏与模块的衔接:将宏保留在单独的头文件中,宏内部调用 TestFramework 命名空间中的函数:
// test_macros.h
#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()
#endif
注意:该头文件必须在 import test; 之后包含。
最终的模块实现
将变量和函数移入名为 test 的模块中,文件名为 test.cppm(C++20 模块文件的常见扩展名):
// test.cppm
export module test;
import std;
export namespace TestFramework {
std::size_t nPass = 0;
std::size_t nFail = 0;
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;
}
void test(
bool cond,
std::string_view expr,
const std::source_location& loc = std::source_location::current())
{
if (cond)
++nPass;
else
fail(expr, loc);
}
void succeed() {
++nPass;
}
void report() {
std::cout << "\nTest Report:\n\n"
<< "\tNumber of Passes = " << nPass << '\n'
<< "\tNumber of Failures = " << nFail << '\
