← 返回信息流
AI 资讯Hacker News·3 天前

25年C++单元测试框架现代化改造(二)

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

速览

本文是系列第二部分,继续讨论如何对一款已有25年历史的最小化C++单元测试框架进行现代化改造。内容涉及代码结构优化、兼容性维护以及现代C++特性(如模板、lambda)的应用。有助于理解C++测试框架的演变与最佳实践。

AI 深度解读

背景

本系列文章的第二部分延续了 Part 1 中介绍的 C++ 单元测试框架。该框架最初设计于 20 世纪 90 年代末,采用极简方式实现自动化测试,包括异常验证。Part 1 指出了两个遗留问题:

  1. 计数器文件局部化:匿名命名空间(namespace { ... })中的 nPassnFail 计数器在每个翻译单元中独立。这一设计最初是为了避免全局变量,且框架初衷是用于单文件学生项目;但在大型项目中,这种限制不必要——项目应能跨所有文件共享总通过/失败计数,同时不违反单一定义规则(ODR)。

  2. 头文件依赖与宏耦合:传统的头文件依赖是 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 17report_ 函数则输出通过次数和失败次数。

第一个问题的解决:inline 变量与命名空间

C++17 引入了 inline 变量(inline variables)。与内联函数类似,inline 变量可以在多个翻译单元中定义,链接器会将所有定义折叠为同一个实体。规则与内联函数相同:

  • 所有定义必须完全一致(相同 token、相同类型、相同初始化器)。
  • 在命名空间作用域下具有外部链接。
  • 保证在所有翻译单元中是同一个对象(地址相同)。

修正方案是选择一个具名命名空间,并将 nPassnFail 声明为 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 << '\
查看原文 →freshsources.com