现代化一个25年的最小化C++单元测试框架(第二部分)
摘要
本文继续系列内容,讨论如何现代化一个25年的最小化C++单元测试框架,重点介绍使用内联变量和模块来解决文件特定计数器和头文件依赖的问题。
暂无内容
查看缓存全文
缓存时间: 2026/07/06 08:01
# 低成本自动化单元测试:第 2 部分 来源:https://freshsources.com/code-capsules/test-part2/ *C++ 代码胶囊* --- 在 第 1 部分(https://freshsources.com/code-capsules/test-part1/)中,我介绍了一种久经考验(即古老 :-))的技术,它以极其简单的方式处理自动化单元测试,包括验证正确的异常处理。然而,上一篇文章留下了两个问题,而解决这两个问题的过程恰好是对现代 C++ 两个关键特性的一次不错探索:**内联变量** 和 **模块**。 第 1 部分所述测试框架的简洁性源于所有内容都包含在一个头文件 `test.h` 中(省略了包含守卫): `` 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); `` 表明文件名是 `tstack.cpp`,调用发生在该文件的第 17 行。如果测试失败,则会在控制台打印: `` FAILURE: stk.top() == 1 in file tstack.cpp on line 17 `` `report_` 函数会输出成功和失败的数量,例如: `` Test Report: Number of Passes = 13 Number of Failures = 0 `` 其他函数用于完整性,但用户很少需要用到。 在本文中,我将修复第 1 部分末尾指出的两个问题: 1. 匿名命名空间中的计数器是每个文件独有的。这是特意设计的,以避免全局变量,并且由于此框架原本用于单文件的学生项目,因此成为一个不必要的限制;大型项目应在所有项目文件中共享成功和失败的总计数,同时不违反单一定义规则(ODR)。 2. 对头文件的依赖长期以来被认为是 C++ 的一个痛点。上述宏调用了匿名命名空间中的内联函数,因此每个被测文件都会拥有自己的代码副本。C++20 引入了模块来缓解此类问题。事实证明,我们仍然需要宏,因此我将采用混合方法,尽可能将内容移到模块中。 --- ## 内联变量 C++17 引入了 `inline` **变量** 的概念。与函数一样,`inline` 变量可以在多个翻译单元中定义,并且链接器需要将所有定义合并为一个。其规则与内联函数相似: - 所有定义必须完全相同(相同的符号、相同的类型、相同的初始化器)。 - 在命名空间作用域中,该变量默认具有外部链接。 - 保证在所有翻译单元中是同一个对象(地址处处相同)。 这里的解决方案是选择一个**命名命名空间**,并将 `nPass` 和 `nFail` 声明为 `inline`: `` namespace TestFramework { inline std::size_t nPass = 0; inline std::size_t nFail = 0; // 其他函数位于同一命名空间中... inline void fail... inline void test... inline void succeed... inline void report... `` 由于所有函数都在 `TestFramework` 命名空间中,我将 `do_fail` 重命名为 `fail`,将 `do_test` 重命名为 `test`。我还去掉了最后两个函数的下划线后缀。只有宏保留了尾部下划线。宏还需要对关联的函数进行完全限定调用,例如: `` #define test_(cond) TestFramework::test(#cond, cond, __FILE__, __LINE__) `` 这些变量满足 ODR,并且不会污染全局命名空间。如果你希望保持纯头文件方式,这是一个不错的解决方案。这很简单。 --- ## 模块迁移 模块是一种更清晰、更现代的封装功能的方式。事实上,既然我们在 C++ 中有了模块,内联变量的需求就大大减少了。现在可以将变量和函数移到它们自己的模块中。在此之前,我们需要决定是否仍然需要宏,以及它们将如何与模块交互。 最初使用宏的主要动机是**将被测试的表达式捕获为字符串**。我知道没有替代方法,因此宏 `test_`、`fail_`、`throw_` 和 `nothrow_` 仍然保留。然而,为了捕获文件名和行号,我将使用 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: 测试模块的宏伴侣。 // 在使用 test_ 的任何测试驱动器中包含此文件。 // 宏不能从模块中导出——此头文件弥补了这一差距。 // 作者:Chuck Allison (Creative Commons License, 2001 - 2026) // 此头文件必须在客户端代码中的 `import test;` 之后出现。 #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 `` 根据当前惯例,我将把变量和函数转移到一个名为 `test` 的模块中,文件名为 `test.cppm`: `` // test.cppm: 简单但有效的自动化测试框架 // C++20 模块版本,使用 import std 和 std::source_location。 // 宏存在于配套头文件 test_macros.h 中(模块无法导出宏)。 // 作者:Chuck Allison (Creative Commons License, 2001 - 2026) 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 << '\n'; } } `` 请注意,`TestFramework` 命名空间也必须显式导出。由于所有内容现在都位于自己的模块中,不再需要 `inline` 关键字。但要使用此模块,必须在包含 `test_macros.h` 头文件**之前**导入 `test` 模块,以便宏能够看到 `TestFramework` 命名空间。以下是第 1 部分中的 `Stack` 测试示例重写为使用 `test` 模块的版本。(我还将 `Stack` 类做成了一个模块。) `` // tstack.cpp: Stack 的测试驱动程序 // C++20 模块版本。 import stack; import std; import test; #include "test_macros.h" int main() { Stack stk; test_(stk.size() == 0); // 测试异常(空栈上 top 和 pop 无效) throw_(stk.top(), std::underflow_error); throw_(stk.pop(), std::underflow_error); nothrow_(stk.size()); // 测试 push 和 top stk.push(1); test_(stk.top() == 1); test_(stk.size() == 1); stk.push(2); test_(stk.top() == 2); test_(stk.size() == 2); // 测试 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_(); } `` 客户端代码的唯一区别是使用了 `import` 并在 import 之后包含了头文件。 --- ## 你实际上应该使用哪一个? 如果你在记分:两个版本都修复了开启本系列问题的 ODR 错误。内联变量修复只需添加一个关键字,并且可以在任何支持 `-std=c++17` 的地方工作——无需模块依赖扫描器,无需 BMI 担忧,无需在作业截止前晚上 11 点向学生解释编译顺序问题。如果你有 C++20 可用,你还可以用 `std::source_location` 替换 `__FILE__` 和 `__LINE__`,并且仍然保持纯头文件。 模块版本带来了真正的封装(不仅仅是礼貌的命名空间),并避免了在每个翻译单元中重新解析头文件,但它要求你处理两个文件,并确保 import 在 #include 之前的顺序正确,而且工具链支持(除了 Visual C++)仍处于起步阶段。 我的建议:对于课程作业以及任何需要在今天、在任何平台上构建的内容,默认使用纯头文件版本。当你的构建环境真正拥有可靠的模块支持,并且你确实想要更强的边界时,再考虑使用模块版本——将其视为最终目标,而不是日常工具,直到生态系统赶上语言发展。模块加头文件的混合方法可能在相当长一段时间内仍是现实。 --- (*注*:此代码是在 Visual Studio 2026 社区版中的 Visual C++ 上开发的。你可以在此处(https://freshsources.com/test.zip)下载代码。感谢 Bjarne Stroustrup 对本文初稿的有益评论。)
相似文章
上下文使测试更具可重用性
作者分享了在Guile中设计测试框架的经验,重点探讨了向测试定义添加上下文如何使测试更可重用并改善开发者体验。
信任你的编译器:现代C++
本文对比了旧的C++性能技巧与现代编译器的能力,表明编译器现在能够将朴素代码优化得比手工调整的技巧更好。包含在AMD Zen 5上使用Clang 21的基准测试。
C语言中在C++中仍然无法工作的构造——以及一些已发生变化的构造
一篇更新经典调查的博文,关于C语言中在C++中无法工作的构造,涵盖了C++20和C23标准中影响兼容性的变化。
基于Markdown的测试套件
作者解释了为EndBASIC的编译器和虚拟机切换到基于Markdown的测试套件的原因,目的是让这些测试作为LLM学习该语言独特特性的权威文档。
关于C扩展、可移植性和替代编译器
本文讨论了编写可移植C代码的实际挑战,这些挑战源于对非标准编译器扩展和glibc条件头文件的依赖,并通过构建C编译器的示例进行说明。