很多同学写过"测试",但大多是指"跑了几遍程序、看了看结果对不对"。真正的自动化测试要的是另一件事:让程序自己动手检查"结果是否符合预期",并且一旦不符合就能清楚地喊出来,连"该往哪里修"都尽量指给你看。而承担这个"动手检查并喊话"职责的,正是这篇文章的主角——断言。

这门课讲的是"自动化测试常用函数"的 C++ 方向。上一篇文章我们讲过自动化测试里怎么找元素、怎么操作对象、切窗口、等加载、传文件、设浏览器参数——那些是 Web 端的"操作方法"(比如 Selenium 里的 find_element、click、send_keys)。而这一篇,我们把镜头对准测试框架本身的"骨架函数":断言怎么设计、用例怎么划分、执行前后怎么搭钩子、一组数据怎么跑同一份断言、最后怎么输出一份能交给 CI 的报告。这几样东西在任何语言、任何测试框架里都是通用的,C++ 自然也不例外。

为了让整套讲解不悬在半空,我会带你亲手搭一个极简 C++ 断言库 + 迷你测试框架。它很小,但五脏俱全:断言宏族、用例注册表、Setup/Teardown 钩子、参数化、报告与退出码,一个不缺。你把它敲下来、编译、运行,亲眼看着它怎么喊出"哪条用例挂了、为什么挂、跑到第几行",自动化测试的"常用函数"就全都活起来了。

你应该有的知识准备

在读这篇文章之前,我默认你对下面这些已经有点概念,它们都是 C 语言 / C++ 基础课里反复出现的,我这里只做一句话回顾:

  • 函数与作用域:一个函数里的局部变量、还有"宏只是文本替换"这一点,是看懂断言宏的地基。
  • #define 宏:我们要写能让参数"只求值一次"、扛得住多语句的宏,必须知道 do { ... } while (0) 这个封装套路。
  • 异常机制:C++ 的 try/catch/throw 是"致命断言用来中断当前用例"的实现手段,你没学过也能看懂用法,但懂了原理会更通透。
  • std::function:一种能"把函数当作值来存、来传"的类型,我们靠它把一堆用例函数存在一张注册表里,这门课只用到最基础的语法。
  • vector / range-for:C++ 序列容器的日常用法,我们的注册表和参数表会用到。

一条都不用精通,能"认出来、顺着注释读懂"就够了。往下读的时候看不懂没关系,动手敲一遍就顺了。


断言:自动化测试的"检验动作"

先给最重要的词下定义。断言(Assertion),在自动化测试里的含义是:在程序里写下一句"这里必须成立"的判定,运行时如果它不成立,就记录(或抛出)一次失败,并如实告诉你"是谁、在哪个文件第几行、对比的两个值各是什么"。你可以把断言想象成流水线上的质检员:产品从他面前过,他量一下尺寸,符合规格就放行,不符合就立刻按铃——这个"按铃"就是断言失败。

断言有两种出处,我们先分清:

  • C/C++ 源生断言 assert:来自 <cassert> 头文件,是最朴素的"检查"。缺点是失败时会把整个程序直接终止,而且很多编译器在 Release(发布)模式下会把它整个禁用掉(因为定义了 NDEBUG 宏)。它适合在"C 写的小程序里查编程错误"。
  • 测试框架自己的断言:就是我们这篇的主角(GoogleTest 的 ASSERT_*/EXPECT_*、Catch2 的 REQUIRE/CHECK,以及我们要亲手写的 ASSERT_EQ/EXPECT_EQ)。它们是给"自动化测试"专门设计的:失败不把程序炸死,而是把"哪条用例挂了、怎么挂的"收集起来,最后输出一份整齐的报告,并且给一个方便脚本判断的退出码。

先看一个源生 assert 的小例子,感受一下最朴素的样子:

// assert_demo.cpp —— 用 C 源生断言做个最小演示
#include <cassert>   // assert 宏的本体,边用边道歉:它其实是个"文本替换"的宏
#include <cmath>     // std::abs:取绝对值,后面判断浮点用
#include <iostream>  // 打印输出
 
int main()
{
    // 这条断言一定成立(1+1==2),所以运行时不报错、不输出任何东西
    assert(1 + 1 == 2);
 
    // 关键演示:浮点数 (0.1 + 0.2) 并不等于 0.3!
    // 所以直接写 == 会"误判失败",正确做法是和一个小容差比较
    assert(std::abs((0.1 + 0.2) - 0.3) < 1e-9);
 
    std::cout << "两条断言都通过,程序正常结束\n";
    return 0;
}

你可以编译运行一下:g++ -std=c++11 assert_demo.cpp -o assert_demo && ./assert_demo。重点看第二条断言——它在教我们一个几乎所有测过浮点的同学都会踩的坑:浮点数不能直接用 == 比较,因为 0.1 + 0.2 在二进制里存不成 0.3 那个精确值。这个坑我们后文专门开一节,请记下这个"伏笔"。

但不是每个场景都够用 assert,尤其当你有一百个用例、只想看"第几个挂了"而不想整体程序崩掉时,assert 就太"粗"了。这就是测试框架断言存在的原因。


一套极简的断言库与迷你测试框架(可直接编译运行)

下面这篇文章的"主轴",是一份能直接 g++ -std=c++17 mini_test.cpp 编译、直接运行、能把上面说的所有概念串起来的完整小程序。我先把它完整摆出来,然后接下来的每一节,我们都围绕它逐段拆解。

// mini_test.cpp —— 连载《自动化测试常用函数(C++方向)》的配套示例
// 一个"教学级"的极简 C++ 断言库 + 迷你测试框架
// 编译运行(GCC / Clang, 要求 C++17):
//     g++ -std=c++17 mini_test.cpp -o mini_test && ./mini_test
// 用 MSVC(Visual Studio) 也可以编译,命令行等价:
//     cl /std:c++17 mini_test.cpp        (中文乱码时再加 /utf-8)
 
#include <cmath>        // std::abs: 浮点容差比较用到的绝对值函数
#include <functional>   // std::function: 把"用例函数/钩子"当作值存进表里
#include <iostream>     // std::cout / std::cerr: 打印结果和失败报告
#include <string>       // std::string: 用例名字
#include <vector>       // std::vector: 全局用例注册表
 
//--------------------------------------------------------------------
// 0. 运行器(Test Runner)用到的全局状态
//--------------------------------------------------------------------
static int  g_assertions   = 0;    // 累计执行过的断言次数(用于最终报告的"总断言数")
static bool g_case_failed  = false;// 当前用例故障标记:任何断言失败都会把它置为 true
 
// 用异常模拟"致命断言对当前用例的中断",这是 C++ 里最贴合"致命"语义的做法
struct FatalAssert {};
 
//--------------------------------------------------------------------
// 1. 被测对象(SUT, System Under Test) —— 你的代码就长这样
//--------------------------------------------------------------------
// 被测函数: "限速器",把速度夹在 [lo, hi] 之间,越界则取边界值
int clamp_speed(int speed, int lo, int hi)
{
    if (speed < lo) return lo;   // 低于下限:取下限
    if (speed > hi) return hi;   // 高于上限:取上限
    return speed;                // 在区间内:原样返回
}
 
//--------------------------------------------------------------------
// 2. 断言宏族(Assertion Macros)
//    命名约定:开头是 ASSERT 表示"致命";开头是 EXPECT 表示"非致命"
//--------------------------------------------------------------------
 
// 非致命相等断言: 相等则无事发生;不等则记录失败,并"继续执行"
#define EXPECT_EQ(lhs, rhs, msg)                                          \
    do {                                                                  \
        ++g_assertions;                                                   \
        auto _l = (lhs);   /* 先用 auto 求一次,避免重复求值/副作用翻倍 */ \
        auto _r = (rhs);   /* 再求右式的值 */                             \
        if (!(_l == _r)) {                                                \
            g_case_failed = true;          /* 打上"本用例已失败"标记 */   \
            std::cerr << "    [EXPECT_EQ 失败] " << (msg)                 \
                      << " : 左=" << _l << " 右=" << _r                   \
                      << "  (" << __FILE__ << ":" << __LINE__ << ")\n";   \
        }                                                                 \
    } while (0)
 
// 致命相等断言: 失败不但记录,还会"抛异常"立刻中断当前用例
#define ASSERT_EQ(lhs, rhs, msg)                                          \
    do {                                                                  \
        ++g_assertions;                                                   \
        auto _l = (lhs);                                                  \
        auto _r = (rhs);                                                  \
        if (!(_l == _r)) {                                                \
            g_case_failed = true;          /* 同样打上失败标记 */         \
            std::cerr << "    [ASSERT_EQ 失败] " << (msg)                 \
                      << " : 左=" << _l << " 右=" << _r                   \
                      << "  (" << __FILE__ << ":" << __LINE__ << ")\n";   \
            throw FatalAssert{};           /* 关键:抛异常让运行器中断本用例 */ \
        }                                                                 \
    } while (0)
 
// 非致命布尔断言: 条件为假则记录失败
#define EXPECT_TRUE(cond, msg)                                            \
    do {                                                                  \
        ++g_assertions;                                                   \
        if (!(cond)) {                                                    \
            g_case_failed = true;                                         \
            std::cerr << "    [EXPECT_TRUE 失败] " << (msg)               \
                      << "  (" << __FILE__ << ":" << __LINE__ << ")\n";   \
        }                                                                 \
    } while (0)
 
// 致命布尔断言: 条件为假则中断当前用例
#define ASSERT_TRUE(cond, msg)                                            \
    do {                                                                  \
        ++g_assertions;                                                   \
        if (!(cond)) {                                                    \
            g_case_failed = true;                                         \
            std::cerr << "    [ASSERT_TRUE 失败] " << (msg)               \
                      << "  (" << __FILE__ << ":" << __LINE__ << ")\n";   \
            throw FatalAssert{};                                          \
        }                                                                 \
    } while (0)
 
// 浮点"近似相等"断言: 差的绝对值不超过容差 eps 就算相等
// (为什么不能用 == ? 见正文"浮点断言"一节)
#define ASSERT_NEAR(lhs, rhs, eps, msg)                                   \
    do {                                                                  \
        ++g_assertions;                                                   \
        double _l = (lhs);                                                \
        double _r = (rhs);                                                \
        if (std::abs(_l - _r) > (eps)) {                                  \
            g_case_failed = true;                                         \
            std::cerr << "    [ASSERT_NEAR 失败] " << (msg)               \
                      << " : 左=" << _l << " 右=" << _r                   \
                      << " 容差=" << (eps)                                \
                      << "  (" << __FILE__ << ":" << __LINE__ << ")\n";   \
            throw FatalAssert{};                                          \
        }                                                                 \
    } while (0)
 
//--------------------------------------------------------------------
// 3. 用例注册表 —— 一张"考试报名表",运行器照着它逐个执行
//--------------------------------------------------------------------
struct TestCase {
    std::string name;            // 用例名字(会显示在报告里)
    std::function<void()> setup;   // 前置钩子(可选,默认空)
    std::function<void()> teardown;// 后置钩子(可选,默认空)
    std::function<void()> body;    // 用例本体
};
 
// 用"函数内静态量"存注册表,避开静态初始化顺序问题(C++ 安全惯用法)
static std::vector<TestCase>& registry()
{
    static std::vector<TestCase> reg;  // 只有第一次调用时才真正创建
    return reg;                        // 之后每次都拿同一个容器
}
 
// 用宏"注册"一条用例: 定义名为 name 的 _body 函数 + 一个静态注册对象,
// 这个静态对象在程序启动(进入 main 之前)时把该函数塞进注册表
#define TEST_CASE(name)                          \
    static void name##_body();                   \
    static struct name##_reg {                   \
        name##_reg() {                           \
            registry().push_back(                \
                TestCase{ #name, {}, {}, name##_body }); \
        }                                        \
    } name##_registrar;                          \
    static void name##_body()
 
// 程序化钩子: 运行前,给指定名字的用例"贴上" setup / teardown
static void attach_hooks(const std::string& name,
                         std::function<void()> setup,
                         std::function<void()> teardown)
{
    for (TestCase& tc : registry()) {            // 遍历注册表
        if (tc.name == name) {                   // 名字匹配就填钩子
            tc.setup    = setup;                 // 填前置钩子
            tc.teardown = teardown;              // 填后置钩子
            return;                              // 找到了就结束
        }
    }
}
 
//--------------------------------------------------------------------
// 4. 演示用的共享状态与"资源"(仅用于演示 Setup/Teardown 和用例隔离)
//--------------------------------------------------------------------
static double g_balance = 0.0;   // 模拟"账户余额"(每个用例都该从干净状态开始)
 
struct FakeLog {                 // 模拟一种必须"打开/关闭"的资源(文件/连接/句柄等)
    bool opened = false;
    void open()   { std::cout << "    [资源] 打开日志文件\n"; opened = true;  }
    void close()  { std::cout << "    [资源] 关闭日志文件\n"; opened = false; }
};
static FakeLog g_log;
 
//--------------------------------------------------------------------
// 5. 普通用例(不带夹具)
//--------------------------------------------------------------------
 
// 用例 A: 演示 EXPECT 非致命——失败后"继续执行"
TEST_CASE(ExpectNonfatalDemo)
{
    EXPECT_EQ(1, 2, "故意失败(非致命)");   // ① 这一条会"失败但不中断"
    std::cout << "    (你能看到这行,说明 EXPECT 失败后用例还在继续跑)\n"; // ② 仍执行
    EXPECT_TRUE(true, "最后一条通过即可");  // ③ 通过
}
 
// 用例 B: 演示 ASSERT 致命——失败后"立刻中断"
TEST_CASE(AssertFatalDemo)
{
    ASSERT_EQ(1, 1, "前置条件1(通过)");   // ④ 先正常通过
    ASSERT_EQ(1, 3, "故意失败(致命)");    // ⑤ 失败 -> 抛异常,中断本用例
    std::cout << "    (你永远看不到这行,因为上一条 ASSERT 已中断本用例)\n"; // ⑥ 不执行
}
 
// 用例 C: 演示 Setup 隔离——余额取现
TEST_CASE(BalanceWithdraw)
{
    g_balance -= 30;                        // 取现 30
    EXPECT_EQ(g_balance, 70.0, "余额100扣30应为70");
}
 
// 用例 D: 演示 Setup 隔离——余额存款
TEST_CASE(BalanceDeposit)
{
    g_balance += 50;                        // 存款 50
    EXPECT_EQ(g_balance, 150.0, "余额100加50应为150");
}
 
// 用例 E: 演示 TearDown——用完资源要及时关闭
TEST_CASE(LogWrite)
{
    EXPECT_TRUE(g_log.opened, "前置钩子已经打开了日志"); // 依赖 setup
    // 这里省掉"真正往文件里写"的细节
}
 
//--------------------------------------------------------------------
// 6. 参数化测试:一张"数据表"产生多条用例(数据驱动)
//--------------------------------------------------------------------
static void make_param_case(const std::string& name,
                            int speed, int lo, int hi, int expected)
{
    TestCase tc;                              // 手工构造一条用例
    tc.name = name;                           // 名字会显示在报告里
    tc.body = [=]() {                         // 捕获数据后跑同一份断言
        EXPECT_EQ(clamp_speed(speed, lo, hi), expected, name);
    };
    registry().push_back(tc);                 // 塞进注册表
}
 
//--------------------------------------------------------------------
// 7. 运行器 + 报告
//--------------------------------------------------------------------
static int RunAllTests()
{
    int run = 0, pass = 0, fail = 0;          // 运行数 / 通过数 / 失败数
    for (TestCase& tc : registry()) {
        g_case_failed = false;                // 每个用例开始前重置失败标记
        bool ok = true;                       // 先假设本用例会通过
        if (tc.setup)    tc.setup();          // ① 前置钩子 SetUp
        try {
            if (tc.body)  tc.body();          // ② 执行用例本体
            ok = !g_case_failed;              // ③ 没致命中断也没失败 => 通过
        } catch (const FatalAssert&) {        // ④ 致命断言触发 -> 该用例算失败
            ok = false;
        }
        if (tc.teardown) tc.teardown();       // ⑤ 后置钩子 TearDown(无论成败都执行)
        ++run;                                // 记运行数
        std::cout << (ok ? "[通过] " : "[失败] ") << tc.name << "\n";
        if (ok) ++pass; else ++fail;          // 累加通过/失败
    }
    // 汇总报告
    std::cout << "------------------- 测试报告 -------------------\n"
              << "总用例  : " << run  << "\n"
              << "通过    : " << pass << "\n"
              << "失败    : " << fail << "\n"
              << "总断言数: " << g_assertions << "\n";
    return fail;                              // 返回失败数,便于设置退出码
}
 
int main()
{
    // (1) 给要用的用例挂上钩子(演示 Setup/Teardown)
    attach_hooks("BalanceWithdraw", []{ g_balance = 100.0; }, {}); // 前置:清零为100
    attach_hooks("BalanceDeposit",  []{ g_balance = 100.0; }, {});
    attach_hooks("LogWrite",
                 []{ g_log.open();  },        // SetUp  :打开资源
                 []{ g_log.close(); });       // TearDown:关闭资源
 
    // (2) 数据驱动地生成"参数化用例"
    struct Row { const char* n; int speed, lo, hi, exp; };
    const Row table[] = {
        { "限速_正常区间",    60, 40, 100,  60 },
        { "限速_低于下限",    30, 40, 100,  40 },
        { "限速_高于上限",   120, 40, 100, 100 },
        { "限速_恰等于下限",  40, 40, 100,  40 },
        { "限速_恰等于上限", 100, 40, 100, 100 },
    };
    for (const Row& r : table)
        make_param_case(r.n, r.speed, r.lo, r.hi, r.exp);
 
    // (3) 跑完所有用例,得到失败数,据此设置退出码
    int failed = RunAllTests();
 
    // 给脚本/CI 用: 返回 0 表示全过,返回非 0 表示有失败
    return failed ? 1 : 0;
}

这份程序本身就能运行,而且它故意包含了两条"会失败"的用例(ExpectNonfatalDemo 和 AssertFatalDemo),目的是让你亲眼看一看"失败报告长什么样""致命与非致命差别在哪"。你敲下来跑一次,会看到类似这样的输出:

[通过] 限速_正常区间
[通过] 限速_低于下限
[通过] 限速_高于上限
[通过] 限速_恰等于下限
[通过] 限速_恰等于上限
[失败] ExpectNonfatalDemo
    [EXPECT_EQ 失败] 故意失败(非致命) : 左=1 右=2  (mini_test.cpp:88)
    (你能看到这行,说明 EXPECT 失败后用例还在继续跑)
[失败] AssertFatalDemo
    [ASSERT_EQ 失败] 故意失败(致命) : 左=1 右=3  (mini_test.cpp:98)
[通过] BalanceWithdraw
[通过] BalanceDeposit
[资源] 打开日志文件
[通过] LogWrite
[资源] 关闭日志文件
------------------- 测试报告 -------------------
总用例  : 10
通过    : 8
失败    : 2
总断言数: 12

看见了吧——它没有因为某条断言失败就崩溃,而是把所有信息一条条记下来,最后给出一张干净的账目:哪几条过了、哪几条挂了、挂在哪里、退出码是多少。接下来几节,我们就一笔一笔拆开讲这背后的门道。


致命断言与非致命断言:ASSERT 一族 vs EXPECT 一族

看到这里你一定注意到,我们的断言其实分成两大"家族",名字以 ASSERT 开头的是致命断言(Fatal Assertion),以 EXPECT 开头的是非致命断言(Non-fatal Assertion)。这两个词是整个测试框架世界里最基础的分野之一,你迟早要和它们打交道。

  • 致命断言(ASSERT_*):一旦失败,"当场中断"当前这条用例,后面的语句不再执行。它的逻辑就是"我这个前提都不成立,后面再测也没有意义了,别浪费时间"。在我们的示例里,它用"抛异常"来实现中断——ASSERT_EQ 失败时 throw FatalAssert{},RunAllTests 里的 catch (const FatalAssert&) 接住异常,这名用例就被记为失败并跳到下一条。
  • 非致命断言(EXPECT_*):一旦失败,只"记录并继续",当前用例剩下的语句照常执行。它的想法是"一次别只报一个错,我把能查的都查完,一起给你"。对我们的测试来说,哪个函数里往往能一口气铺满十条 EXPECT_{...},失败了也一个不漏地都报出来,这样一次运行能暴露的问题更多,改效率反而高。

回到程序里的用例 A (ExpectNonfatalDemo):EXPECT_EQ(1, 2, ...) 故意失败后,"你能看到这行"那行 cout 照样打了出来,这就是"非致命不断片"的铁证。而用例 B (AssertFatalDemo) 里,ASSERT_EQ(1, 3, ...) 一失败就中断,它下面那行 cout 你永远看不见。

一个实战里极有用的判断准则:把"前置准备类"的检查用致命断言,把"结果比较类"的检查用非致命断言。比如你测"用户登录后能看到订单列表":登录是否成功就是前置,一旦登录失败,"后续所有关于订单的断言"都失去了意义,这时就该用 ASSERT_TRUE(登录成功, ...);而"列表里有没有订单A、有没有订单B"这类是并列的结果检查,用 EXPECT_ 一族更合适,这样坏一个不牵连另一个。

需要顺嘴提醒一句:上面这些 ASSERT_* / EXPECT_* 的命名与语义,遵循的是 GoogleTest(原 googletest,C++ 最流行的测试框架)的经典约定;Catch2 那套则叫 REQUIRE(致命)/ CHECK(非致命),换个名字,同一个道理。你以后遇到别的框架,记着"致命/非致命"这对概念即可,名字不过是外壳。

思考题

问:为什么在写断言宏时,做相等比较要用 auto _l = (lhs); 先存一下,而不能直接写 if (lhs == rhs)?

参考答案:这是为了"只求值一次"。宏是纯文本替换,EXPECT_EQ(a, b, ...) 会原样展开成带 (a) == (b) 的代码。如果 a 或 b 是一个自带副作用的表达式,比如 EXPECT_EQ(++count, 5, ...),直接写 if ((lhs) == (rhs)) 会被展开成 if ((++count) == 5)——++count 只求值一次,这没问题;但真正可怕的是同一个表达式出现在两处,比如你把 (lhs) 既要比较、失败后又要在 << _l 里打印一次,那副作用就会执行两次(++count 被加两次)。所以先在 do { } 块里用 auto _l = (lhs); 求一次并存起来,比较、打印都用这个变量,副作用就只有一份。这正是宏"防不胜防"的经典坑,和我们入门篇里讲的 #define SQUARE(x) ((x)*(x)) 配上 SQUARE(a++) 自增两次是同一回事。


断言失败之后:这条用例还会继续跑吗

这是一个初学自动化测试几乎人人都会问的问题。答案其实分两个维度,取决于两件事:

第一,看断言是致命的还是非致命的。 致命断言失败 = 立刻抛异常 = 当前用例立刻终止;非致命断言失败 = 记一笔,继续往下跑。这一点上一节已经演过一遍。

第二,看"当前用例终止"对"整体测试过程"意味着什么。 关键要区分两层:

  • 一条用例(一个测试函数)中断,不影响别的用例。AssertFatalDemo 虽然自己挂了、自己的 cout 没打出来,但 RunAllTests 会收拾好异常,继续跑到 BalanceWithdraw、BalanceDeposit……一条都不落下。这被称作"用例之间互相独立、互不拖累"。
  • 而在一份报告里,这条用例被记为"失败",会让整体退出码变成非 0,从而让 CI(持续集成,Continuous Integration,自动化构建并跑测试的流水线)知道"这次构建不过关"。

这里掏出一个会被反复引用的本质:"让测试继续跑"和"让测试结果有效",是两回事。 非致命断言的取向是一次暴露更多问题;致命断言的取向是避免无意义的后续运算。所以,"断言失败后要不要继续"从来不是一句简单的"是或否",而是"这条用例内部怎么走 + 整体测试怎么跑"两个层面配合的结果。

思考题

问:假设某条用例里有 8 条连续的 EXPECT_EQ,第 3 条失败了。请问第 4~8 条还会执行吗?整份测试的退出码会变成非 0 吗?

参考答案:会。因为 EXPECT_* 是非致命断言,失败只把"本用例失败标记"置为 true 并继续,所以第 4~8 条照常执行。整份测试的退出码也会变成非 0——因为程序末尾 return failed ? 1 : 0; 用的是"累计失败数",任一用例失败都会让 failed > 0。换句话说:非致命只是"不中断当前用例",并不妨碍把这个失败如实计入最终结果。


浮点断言:为什么绝对不能直接比较相等

这是自动化测试里翻车率高到离谱的一块,值得单独开一节。请先把这句话刻进脑子:对浮点数(float/double),永远不要用 ==/!= 判断相等。

原因要从浮点数的表示讲起。计算机里的浮点数是"二进制科学计数法":一个数被表示成符号位 + 指数 + 尾数三部分。问题在于,很多你在十进制里"一眼就是有限小数"的数,比如 0.1、0.2、0.3,在二进制里是无限循环小数,根本存不进有限的尾数里,只能存一个最接近的近似值。于是 0.1 + 0.2 的真实结果是一个很接近 0.3、但不等于 0.3 的近似值。你可以跑跑这段:

// float_demo.cpp —— 亲眼看看浮点"相等"有多不靠谱
#include <cmath>     // std::abs
#include <iomanip>   // std::setprecision:控制打印的小数位数
#include <iostream>
 
int main()
{
    double a = 0.1, b = 0.2;
    double c1 = a + b;     // 0.1+0.2
    double c2 = 0.3;       // 想要的结果
    std::cout << std::setprecision(17)               // 打印 17 位有效数字
              << c1 << "\n"                          // 输出: 0.30000000000000004
              << (c1 == c2) << "\n"                  // 输出 0,即 false!
              << std::abs(c1 - c2) << "\n";          // 输出一个很小的正数,即"差多少"
    return 0;
}

看到最后那几行你就明白:0.1 + 0.2 == 0.3 在 double 里求出来是"否"。如果我们的被测代码干的是"应收金额 = 单价 × 数量"、"温度换算"这类活,直接拿 == 去对预期值,十有八九会误报失败——明明代码没错,测试却天天喊挂,这是最伤团队士气的事故之一。

那怎么比才对?正确的思路是"近似相等":只要求两个数足够接近,而不是"完全相等"。于是我们给它留一个容差(tolerance)eps,只要 abs(a - b) <= eps 就认为相等。上面完整框架里的 ASSERT_NEAR(lhs, rhs, eps, msg) 干的就是这件事:

|左值 - 右值| 不超过 eps  ⇒  判定相等
|左值 - 右值| 超过   eps  ⇒  判定失败

容差 eps 怎么选也是一门小学问:

  • 数值量级很小、且本身精度要求不高的场景,可以给一个绝对容差,比如 ASSERT_NEAR(体积, 100.5, 1e-6, "体积计算")。
  • 但绝对容差有个毛病:量级一大就不灵了。1000000.0 和 1000000.00001 的差是 1e-5,用 1e-6 去比就会误判失败,可这个差距相对 100 万来说小得无足轻重。所以大型数值更推荐"相对容差":abs(a-b) <= tol * max(|a|,|b|),也就是"允许千分之一/万分之一的误差",量级无关。
  • GoogleTest 更狠一点,提供了 ASSERT_FLOAT_EQ / ASSERT_DOUBLE_EQ,底层用的是 ULP(Unit in the Last Place,最后位单位,一种衡量"两个浮点中间隔了多少个可表示的精度台阶"的度量),默认允许 4 个 ULP 的误差,按位级台阶来判断浮点是否"足够接近"。它在处理"极大/极小两边都难"的时候更稳,但原理对新同学来说可以先不深究,记住"它也是在容差框架内比较"就够了。

一句话小结:凡是浮点断言,一律按"近似 + 容差"来写,别用 ==。 这不只是让测试少误报,更是告诉读你代码的人"我知道这里存在舍入误差,我也容忍它",是一种工程质量上的自觉。


断言消息:给失败留一句"人话"

自动化测试讲究"失败要能自解释"。所以几乎所有框架的断言都支持断言消息(Assertion Message):一段在断言失败时会随报告一起打出来、用来描述"这个断言到底想验证什么"的文本。它是失败报告的"解说词",能大幅缩短排查时间。

在我们的迷你框架里,因为是想让你看得清楚,我们把消息做成了宏的第三个参数 msg(真实的 GoogleTest / Catch2 则用流式写法 EXPECT_EQ(a, b) << "当前是第" << i << "轮";,这是 C++ 运算符重载的妙用,我们这里不做那么花哨的展示):

EXPECT_EQ(clamp_speed(speed, lo, hi), expected, name);

你看这行:第三个参数 name 就是"这条参数化用例的名字",一旦 clamp_speed 返回值和预期不符,报告里就会把 name 原样打出来——你直接就知道"是限速_低于下限这条挂了"。这比光盯着 左=40 右=100 去猜意图要省心太多。

给断言消息提三条实用的建议:

  1. 描述意图,别复述代码。 写 "余额100扣30应为70",而不是 "检查余额变量"。前者说清楚"业务上期待的是金额变化",后者等于什么都没说。
  2. 带上能定位"是哪一组"的上下文。 尤其在参数化测试里,把当前参数的辨识信息(第几组、什么名字)塞进消息,失败报告才不至于变成一堆同质化的红字。
  3. 宁可啰嗦,不可缺失。 消息不嫌长,最怕没有。一个孤零零的 "Assertion failed" 在百条用例里根本没法定位。

用例(Test Case)与用例组织

再立一个概念。用例(Test Case),自动化测试里的最小执行单元:它就是"一个测试函数"——带着一组输入、规规矩矩地执行被测代码、最后用断言校验结果。我们框架里一个 TEST_CASE(...) 就定义并注册了一条用例;运行器里它对应 TestCase 结构体里那个 body。

一个工程里的用例不可能只有一条,于是就有了**用例组织(Test Case Organization)**的问题——按什么结构把它们摆整齐。主流框架普遍提供"三层"划分:

  • 用例(Test Case):最小的可独立执行单元,就是上面说的一个测试函数。
  • 测试套件 / 测试类(Test Suite / Test Fixture):把"同一类被测功能的用例"归拢到一起的分组。GoogleTest 里用 TEST_F(FixtureName, caseName) 表示"这条用例属于哪个夹具";Catch2 用 TEST_CASE(...) 里的 tag 标签分组。分组的意义在于:同一组的用例往往共享"同一批准备数据"——而这正是下一节 Setup/Teardown 钩子登场的舞台。
  • 测试二进制 / 测试工程:更外层,把所有用例外加运行器、断言库、报告拼成一个可执行程序,交给 CI 去跑。

我们把概念具象化:被测对象是一个"银行账户",那么"账户模块"就是一个套件,套件下有若干用例:最小金额校验、负数金额拒收、余额不足拒付……它们共享同一份"初始化的空账户"作为准备数据。这样一个清清爽爽的树状结构,能让测试像目录一样一目了然,也能在失败时快速定位"是哪一坨挂了"。

思考题

问:用例(Test Case)和测试套件(Test Suite)的本质区别是什么?为什么要强调"分组"?

参考答案:区别在粒度与共享范围。用例是最小的可执行单元(一个测试函数、一段断言逻辑);而套件是把"相邻、同主题的用例"组织在一起的分组容器,核心价值是"让组内用例共享同一套准备/清理逻辑"(即 Setup/Teardown)和同样的命名域。强调分组有几个好处:一是可读性,失败日志能按模块归类;二是可以只跑某个套件(比如"版本迭代只回测账户模块"),节省时间;三是能复用共同的夹具代码,避免每条用例都重复写"造账户"的样板。


Setup 与 Teardown 钩子:执行前的准备、执行后的收拾

现在来讲自动化测试里重量级的两个"钩子"。钩子(Hook)这个词,指的就是框架在某个特定时机自动回调你提供的函数。测试框架在"每一条用例执行前"和"每一条用例执行后"各留了一个钩子,分别叫:

  • SetUp(前置钩子):有的框架叫 BeforeEach / SetUp。它在每条用例执行前自动调用,典型用途是做"准备":初始化被测对象、造测试数据、打开文件/数据库/网络连接、登录账号。
  • TearDown(后置钩子):有的框架叫 AfterEach / TearDown。它在每条用例执行后自动调用,典型用途是做"清理/兜底":关掉资源、删除临时数据、恢复被改坏的全局状态。

注意它们的"频次"是每执行一条用例,就跑一遍 SetUp 和 TearDown——不是整个测试跑一遍只跑一次。这一点极其重要,它是"用例隔离"得以成立的第一道保障。

在我们迷你框架里,钩子被存进 TestCase 的 setup / teardown 两个 std::function 字段,运行器 RunAllTests() 在跑每条用例时这样编排顺序:

g_case_failed = false;            // 先把上次的失败标记清掉
if (tc.setup)    tc.setup();      // ① 前置钩子 SetUp
try {
    if (tc.body)  tc.body();      // ② 用例本体
    ok = !g_case_failed;          // ③ 判定是否通过
} catch (const FatalAssert&) {
    ok = false;                   // ④ 致命断言 -> 失败
}
if (tc.teardown) tc.teardown();   // ⑤ 后置钩子 TearDown(无论成败)

这里有个非常关键、也容易踩坑的顺序真理:不管用例成败与否,TearDown 都必须执行。 所以我们把它放在 try/catch 外面——即便用例因为致命断言抛了异常、被 catch 接住,tc.teardown() 也照样跑。想象一下:你的用例打开了数据库连接,如果中途断言失败就"慌张终止、忘了关连接",数据库连接就会越积越多,最后把资源池拖垮。TearDown 正是为了兜住这种"半途而废"而存在的。

程序里 LogWrite 这条用例演示的是最标准的用法:SetUp 里 g_log.open() 打开"日志文件",用例本体专心校验"它确实打开了",TearDown 里 g_log.close() 收盘。你跑起来会看到"打开日志文件 / 关闭日志文件"成对出现——这就是钩子日复一日的"执子之手,各有归宿"。


用例隔离(Test Isolation):每个用例都从零开始

这一节讲自动化测试的一条"铁律":用例隔离(Test Isolation)——每个用例都应该从一套干净、可预期的初始状态开始,互不干扰。如果做不到这一点,测试就会随机出现"玄学失败":单独跑这条用例好好的,一整套一起跑就挂了,而且听天由命。

为什么必须隔离?因为用例一旦共用全局/静态状态,前一条用例留下的"残值"就会污染后一条用例的结论,让没有 bug 的代码报出失败(这叫"虚假失败",false positive)。举个例子,程序里的两个余额用例:

TEST_CASE(BalanceWithdraw) { g_balance -= 30; EXPECT_EQ(g_balance, 70.0, "余额100扣30应为70"); }
TEST_CASE(BalanceDeposit)  { g_balance += 50; EXPECT_EQ(g_balance, 150.0, "余额100加50应为150"); }

它俩都依赖"开跑时 g_balance == 100.0"。假如我们把 main 里这两个 attach_hooks(...) 挂的前置钩子删掉,会发生什么?两条用例共享同一个全局 g_balance,谁先执行谁把余额改掉——先跑 BalanceWithdraw 把余额从 0 变成 -30,再跑 BalanceDeposit 从 -30 变 20,断言"应为 150"就没法成立,代码没写错,测试却失败了。这就是典型的"用例间互相污染"。

在真实的 C++ 框架里,真正的隔离靠的是更彻底的一招:为每一条 TEST_F 用例创建一个全新的夹具对象实例。夹具(Fixture)是一个类,你在它的 SetUp() 里初始化成员、在 TearDown() 里清理,然后这条用例的 TestBody() 是它的成员函数——于是"准备数据"天然是每一条用例一份、用完即弃的,而不是全局共享一份。这就从根上杜绝了"残值污染",因为压根没有可供残留的共享对象。我们迷你框架为了把代码量压到最小,是用"全局状态 + 每条用例挂自己的 Setup 钩子"来模拟隔离效果的,概念一致,但请你务必知道:真实生产框架的更优解是"每用例一个新实例"。

给隔离实践列几条可操作的口诀:

  1. 不要依赖执行顺序。 你的用例不应当假设"谁一定在我前面跑"。
  2. 不要共享可变全局/静态状态。 非共享不可,就靠 Setup 每次重置到确定值。
  3. 临时数据用完即删,资源用完必关。 这正是 TearDown 的职责。
  4. 能用局部变量就不用全局。 能用每用例新实例(夹具)就不用裸全局。

思考题

问:为什么"用例之间传数据/共享状态"在自动化测试里被视为一种反模式?请结合"虚假失败"解释。

参考答案:因为共享可变状态会让一条用例的执行结果依赖另一条用例的执行顺序,而顺序往往由框架内部调度决定、对用例作者不透明。一旦 A 用例改了共享值,B 用例读到的就不是它以为自己能读到的干净初始值,导致 B 无缘无故失败(虚假失败)。这种"由测试环境而非被测代码引发的失败"最难排查:你单跑 B 是过的,全量跑才挂,干扰了真实的 bug 判断。所以规范做法是让每一条用例拥有自己的干净环境(每用例新实例 + Setup 初始化),这样执行顺序无关、结果可复现、失败可归因。那句"测试要确定、要可复现"的根,就在隔离上。


参数化测试:一组数据跑同一份断言

参数化测试(Parameterized Test,也译作"数据驱动测试"),指的是:同一段断言逻辑,用一批不同的输入数据分别执行,每一组数据算"一条用例"。它解决的不是"怎么测",而是"别让同质化的测试写成几百行重复代码"。

假设我们要验证"限速器"clamp_speed 的正确性,手写八条用例当然可以,但会变成这样一堆几乎一样的代码。参数化的思路是把这个矩阵抽出来:只写一句断言"返回值应当等于预期",然后给一张表,每行填"输入速度、下限、上限、期望输出",框架自动生成一条条用例:

用例名输入速度下限上限期望输出
正常区间604010060
低于下限304010040
高于上限12040100100
恰等于下限404010040
恰等于上限10040100100

程序里 make_param_case(...) 干的就是"拿一行数据,造一条用例";main 里那张 table[] 就是数据表。注意一点:这五条用例在报告里是五条独立用例,各自有名字,失败时能精确指出"是哪一组数据挂的"。这比"在一个 for 循环里跑完、整体只算一条"更利于排查——循环法虽然也能跑,但一旦中间某组挂了,往往需要靠调试才能知道具体是第几个参数,可读性差很多。

参数化测试还有一个特别大的价值:它是"边界值分析"的最顺手载体。测试的黄金法则之一就是盯住边界——低于下限、高于上限、恰等于下限、恰等于上限,这些"刚好卡在边界上"的数据最容易把实现里的 > / >= 写反的 bug 揪出来。用一张参数表把这些边界一网打尽,既快又全。

注:面向大规模数据的参数化,业界已有标准化做法,即 数据驱动测试(Data-Driven Testing)。核心思想是把"测试数据"和"测试代码"分离——数据可以来自一张表、一个 JSON、一个 CSV,甚至外部文件,而断言代码完全不变。对于 C++ 侧,GoogleTest 提供 INSTANTIATE_TEST_SUITE_P 在编译期生成多个参数实例,Catch2 提供 TEMPLATE_TEST_CASE / GENERATE 等;我们迷你框架这版用"运行时往注册表 push"来演示同样的数据驱动精神。数据越多,数据驱动越划算。

对比维度手写多条用例循环(单用例)跑一遍参数化(数据驱动)
代码量大,全是样板小小
每条数据独立计成败天然独立不独立(一条全收)独立
失败定位到具体数据看代码行难,要调试用例名自带数据说明
改数据(增删边界)增删整段代码改数据数组改数据行即可
数据与逻辑分离否否是

一句话:想在自动化测试里高效、健壮地覆盖"一堆同样形状的数据",参数化是默认正解。

思考题

问:为什么说"在参数化测试里,用例名里带上当前这组数据的信息"特别重要?如果不带,会遇到什么麻烦?

参考答案:因为当数据变多、失败变多时,报告是你唯一的"航海图"。如果五条参数用例都叫同一个名字"校验限速器",其中两条挂了,报告里就是两个一模一样的红字名字——你在几十上百条输出里根本分不清是哪两组数据挂了,还得重新翻到数据表去逐行对号。把名字加上数据(如"限速_低于下限"),报告一条命令就告诉你是哪组边界出了问题,排查时间从十分钟缩到十秒。这正是"失败要能自解释"这一理念的又一次体现。


测试报告与退出码:让自动化"能自助判断成败"

自动化测试的最终使命是"放进流水线里自己跑、自己判断、无须人盯"。完成这个使命要两样东西:一份人读的测试报告,和一个机器读的退出码。

先看报告。好的报告至少该包含:每个用例的名字和通过/失败状态、失败时的断言消息与出错位置(文件:行号)、以及汇总统计(总共跑了几个、几个过、几个挂、总共多少断言)。我们 RunAllTests() 结尾那段"--- 测试报告 ---"就是它的朴素实现,GoogleTest / Catch2 还能输出 XML/JSON/JUnit 格式,方便各种报告工具消费。

再看退出码,这是自动化里最能"说话"的部分。一个普通 C++ 程序的 main 返回一个整数,约定为:

  • 返回 0:程序正常结束(一般表示成功)。
  • 返回 非 0:发生异常(数值具体是 1 还是 128 之类,往往由调用方约定)。

我们的 main 末尾有一行老演员:

return failed ? 1 : 0;   // 有失败:返回1(构建不过关);全过:返回0(构建通过)

这样一来,CI 里只要写一句类似 ./mini_test && echo "构建通过" 的判断:进程退出码是 0,说明测试全绿,继续往下走;退出码非 0,说明有红,构建立刻被标记为失败。哪怕没人在屏幕前盯着,CI 也能一眼看出这次构建到底坏没坏——这就是"自助判断成败"。

还有一个小坑值得点出来:退出码的"非 0"只是"失败"的代号,别依赖它做精细的区分。 如果你的测试框架既把"用例失败"又把"测试环境配置错误"都映射成退出码 1,那报告没法靠退出码区分这两者——判断还是要回到报告文本 / 结构化输出(XML/JSON)上去。所以原则是:退出码管"过/不过"这种二值判断,详细原因看报告。


C++ 常见断言库 / 测试框架的封装思路

讲到这里,主角迷你框架已经把你带过了断言、钩子、参数化、报告、退出码的全流程。最后我们来"抬头看路":现实中,这些能力在 C++ 的主流测试框架里是怎么封装出来的。 看懂下面这几招,你以后阅读任何框架代码、甚至自己封装内部测试工具,都会豁然开朗。

第一招:把"断言"做成一族宏,而不是函数。 原因很朴素:宏能用 __FILE__、__LINE__ 自动填上"当前文件、当前行号",还能带着表达式文本一起输出来,函数做不到。所以你会发现 GoogleTest、Catch2 的断言全都是宏族。这是"为什么我们用宏定义断言"的根源。

第二招:失败处理靠异常 + RAII,不让局部资源泄漏。 我们顺手实现了"致命断言用异常中断"。生产框架更讲究:异常会一路把栈弹开,这个过程中被弹毁的局部对象会连带着跑析构函数(RAII,Resource Acquisition Is Initialization,"资源获取即初始化"——把资源管理绑在对象生命周期上的惯用法,对象销毁则资源自动释放),所以即使中断,本用例里申请的局部资源也能正确释放,这就是"致命中断也不会搞出资源泄漏"的底气。

第三招:用一个"注册表 + 静态注册对象"把用例收集起来。 我们 TEST_CASE 宏的肚子里,有一个 static struct name##_reg,它作为全局对象的构造器在进入 main 之前执行,从而赶在测试启动前把用例自动塞进注册表。GoogleTest 大致也是这套"静态注册"的思路(TEST 宏展开后注册到内部的 TestInfo 列表)。这个"用全局对象构造器做委托注册"的惯用法,是 C++ 测试框架的心脏。

第四招:把"共用准备数据"封装成夹具(Fixture,继承一个专门基类、重写 SetUp/TearDown)。 我们迷你框架用"全局 + 钩子"模拟,而生产框架用类继承:每条 TEST_F 用例都会 new 一个全新的夹具实例,在它上面调用 SetUp → TestBody → TearDown。这就把"每用例一个新实例"的隔离做成了语言层保证,而不是靠约定。

第五招:用"数据表 / 生成器"动态扩展用例个数,实现参数化。 静态注册配合运行时生成(我们 make_param_case 直接 push),或编译期模板生成(GoogleTest 的 INSTANTIATE_TEST_SUITE_P、Catch2 的 TEMPLATE_TEST_CASE),本质都是"用一份逻辑 + 多份数据 => 多条用例"。数据与逻辑的分离程度,决定了这套测试能撑多大规模。

最后一招:把"结果收集 + 报告渲染 + 退出码"做成一个可配置的"运行器"。 用一个统一入口收集所有用例,统一判定、统一打印、统一构造退出码,甚至支持按名字/标签过滤("只跑某个套件")、超时、并发等扩展点。这就是为什么 GoogleTest 只需要 main 里一句 RUN_ALL_TESTS(),Catch2 更是连 main 都不用写、编译选项就能生成主入口——因为它把运行器的活全包了。

上面这些招数组合起来,就构成了"一个自动化测试框架 API"的外貌。而你亲手写的迷你版,恰恰把其中最重要的五个环节(断言宏、注册表、钩子、参数化、报告+退出码)都走了一遍。框架不是神通,是把这些朴素机制按工程规范缝在一起。

综合自测(最后一组练习)

问 1:一份真实测试中,某次运行结果"10 条用例,10 条都通过了,总断言数 12"。请问在这个迷你框架的输出里,退出码应该是多少?为什么?

参考答案:应为 0。因为 main 末尾 return failed ? 1 : 0;,而 failed 来自 RunAllTests() 的返回值(累计失败用例数)。既然 10 条全过、失败数 failed == 0,就返回 0,表示构建通过。注意"总断言数 12"是"所有用例里断言被执行的总次数",与用例的通过/失败统计是两套维度,它不影响退出码,只用来反映断言覆盖强度。

问 2:为什么"登录成功后断言订单列表"这种用例里,"登录是否成功"要用致命断言(ASSERT_TRUE),而"订单 A 是否存在、订单 B 是否存在"要用非致命断言(EXPECT_)?

参考答案:因为前者是后者成立的前提(前置类检查)。如果登录失败了,后面所有关于订单的断言都失去了含义,继续傻跑只会产生一连串"无意义的失败噪音"——所以用致命断言,登录一挂立刻截断这条用例,报告干净、语义清楚。而订单 A 和订单 B 是并列的结果检查,彼此独立,用非致命断言可以让一次失败不牵连别的项,一次跑完把所有问题都暴露出来。这正是上文的判断准则:前置条件用致命,并列结果用非致命。

问 3:浮点断言为什么"绝对容差"在大数值场景下会误报失败?请给出一个具体量级来说明。

参考答案:因为绝对容差是"固定数值的差"来衡量,而数值的量级会被忽略。例如容差 eps = 1e-6,两值 1000000.0 与 1000000.00001 的差是 1e-5,超过了 1e-6,于是被判为失败;但这 1e-5 的相对 100 万,只相当于亿分之一的误差(1e-5 / 1e6 = 1e-11),在业务上完全可忽略。换句话说,绝对容差"看的是够不够近",而大数场景真正该看的是"近的比例够不够"。正因如此才要改用相对容差 abs(a-b) <= tol * max(|a|,|b|),或者用 GoogleTest 的 ULP 比较来适配不同量级。

问 4:把"打开数据库连接"写在用例本体里、而不是写在 SetUp 里,会带来什么问题?请结合 TearDown 的运行时机作答。

参考答案:至少带来三个问题。其一,代码重复——每个要用连接的用例都得自己写一遍"打开连接",样板膨胀;其二,容易不一致——有人开、有人忘关,出现连接泄漏;其三,关键的是关闭时机没保障——如果连接在用例本体里打开,却在中途遇到致命断言被异常截断,那行 close() 很可能根本没执行到,连接就泄漏了。而放在 SetUp 开、TearDown 关,因为 TearDown 在 try/catch 之外、无论如何都会执行,连接一定能被收回。这也再一次呼应了"资源获取/释放要绑定到钩子生命周期"这条规范化思路。


写在最后

我们从"断言是什么"出发,亲手搭了一套极简的 C++ 断言库 + 迷你测试框架,然后沿着它的脉络把自动化测试"常用函数"的五大构件拆了个遍:断言(致命/非致命、浮点的坑、断言消息)、用例组织与隔离(何为用例、为何每个用例都要从零开始)、Setup/Teardown 钩子(准备与收拾、时序的正确姿势)、参数化(数据驱动、边界覆盖)、以及报告与退出码(人读的账本 + 机器读的开关)。

如果你跟着把 mini_test.cpp 敲下来、亲眼看着它打印出那两条"故意失败"的红色用例,再试着删掉一个 Setup 钩子看隔离如何崩坏、加一行边界数据看参数化如何生效——这套功夫就算真正长在手里了。这些能力全部是跨语言、跨框架通用的:你明天的项目换成 GoogleTest、Catch2、CppUnit,甚至换成 Python 的 pytest、Java 的 JUnit,会发现名字虽然在换,但"致命/非致命、SetUp/TearDown、参数化、报告与退出码"这一整套骨架从未变过——你学的不只是某个框架,而是"自动化测试为什么这样组织"这门通用内功。

下一篇文章,我们沿着测试这条线继续往下走:聊聊测试数据与测试环境的准备,看自动化测试在真实工程里怎么把"可靠的输入"和"干净的运行环境"这两个最让人头疼的前提,稳稳地搭起来。


参考资料(可按需查阅官方文档核对细节):

  • GoogleTest 官方文档(Primer / Assertions / 关于致命与非致命断言、Fixture SetUp/TearDown、INSTANTIATE_TEST_SUITE_P 参数化等)。
  • Catch2 官方文档(REQUIRE/CHECK、tag、SECTION、TEMPLATE_TEST_CASE/GENERATE)。
  • cppreference.com 关于 <cassert> 的 assert 宏、NDEBUG 语义,以及 std::function 的相关说明。
  • 浮点比较原理可参考 IEEE 754 浮点标准与"ULP(Unit in the Last Place)"相关阐述。