面向零基础读者:术语首次出现用通俗类比解释,代码全部带中文注释,关键易错处有 ⚠️ 预警,文末附 FAQ 速查表。本文基于 Catch2 v3(当前主线版本),并会指出 v2 的差异。
一、为什么我们需要单元测试?
先想一个场景:你写了一个计算器类 Calculator,能加能减能乘能除。代码写完,你手动输入几个数字试了试,感觉"没问题",就上线了。一周后,同事改了个小数点处理,结果除法全乱套——但你根本不知道是哪一步改坏的。
单元测试(Unit Testing)就是给这段代码上的"安全网":
- 把每一个函数/类的行为,拆成一条条可自动执行的"考题";
- 每次改完代码,一键把所有考题重新跑一遍;
- 哪道题挂了,就说明哪个功能被改坏了,定位又快又准。
C++ 里写单元测试,Catch2 是当下最流行的开源框架之一(GitHub 上数万 star),因为它把"写测试"这件事变得极其轻松。
二、Catch2 是什么?(先来个通俗类比)
想象你是一个老师,要给 100 个学生出卷子、批卷子:
| 传统方式(裸写断言) | Catch2 的方式 |
| 自己手写"考务系统"(测试框架) | Catch2 就是现成的考务系统,你只管出题 |
| 考砸了只报"第 3 题错了" | 自动告诉你:期望 4,实际得到 3,在哪一行挂的 |
| 想抽考一部分学生很麻烦 | 用标签(tag)随便圈定"只考这 5 个学生" |
| 批卷结果要自己整理成报表 | 自动输出人类可读 / XML / JUnit 多种报表 |
Catch2 的核心口号是:"一个只有头文件、不需要额外配置、写起来像自然语言"的测试框架。你写的测试代码长这样:
TEST_CASE("加法测试", "[calculator]") { Calculator calc; REQUIRE(calc.add(2, 3) == 5); // 像一句英语:REQUIRE(要求)2+3 等于 5 }- TEST_CASE:声明一道"考题"(一个测试用例)
- REQUIRE:检查一条断言,失败就报错
三、使用优点(含对比表格)
3.1 与传统 assert 相比
很多人初学 C++ 会直接用 <cassert> 里的 assert 写"测试",两者有天壤之别:
| 维度 | 传统 assert | Catch2 |
| 使用目的 | 调试期校验程序不变量("这里不该发生") | 验证功能行为("这个功能该产出什么") |
| 发布版行为 | 定义 NDEBUG 后完全消失,等于没测 | 测试代码与业务代码分离,发布版不含测试,但测试本身永远可跑 |
| 失败信息 | 只输出文件名+行号,"expression failed" | 自动展开表达式左右值:"Expected: calc.add(2,3)==5\nActual: 4" |
| 组织方式 | 零散地塞在业务代码里 | 独立测试文件、按 TEST_CASE 分类、可打标签 |
| 批量执行 | 靠自己写 main 循环 | 一条命令跑全部/按标签跑部分 |
| 断点续查 | 第一个 assert 失败程序就崩(abort) | CHECK 失败不中断,一条用例内能查出所有问题 |
3.2 与其他 C++ 测试框架对比
| 维度 | Catch2 | Google Test (gtest) | Boost.Test |
| 上手门槛 | ⭐ 最低:宏写起来像英语,文档友好 | 中:概念较多(Test Suite/Fixture/Matcher) | 高:Boost 体系重,宏冗长 |
| 安装复杂度 | 低:v2 单头文件即用;v3 一个 CMake target | 中:需要编译 gtest 库并链接 | 高:需要引入整个 Boost |
| 测试组织 | TEST_CASE + SECTION(天然支持"用例内子场景") | TEST_F(依赖 fixture 类,样板代码多) | BOOST_AUTO_TEST_CASE(功能全但繁琐) |
| 断言失败处理 | REQUIRE 中断 / CHECK 继续,灵活切换 | ASSERT_* 中断 / EXPECT_* 继续 | BOOST_REQUIRE / BOOST_CHECK |
| 失败信息可读性 | 表达式自动展开左右值,最友好 | 较好 | 一般 |
| 数据驱动 | Generators(内置,零额外代码) | 需要 INSTANTIATE_TEST_SUITE_P + 参数化类,样板多 | 参数化样板较多 |
| BDD 风格 | 内置 SCENARIO/GIVEN/WHEN/THEN | 需要 gtest-bdd 扩展 | 无原生支持 |
| 报表输出 | Console / JUnit / XML / SonarQube 等 | XML 为主 | 文本为主 |
一句话总结:gtest 功能全面但样板多,Boost.Test 大而全但重;Catch2 胜在零配置、读起来像英语、写起来最省事——特别适合中小型项目、教学和快速验证。
四、使用场景
- 算法/数据结构正确性验证:排序、图算法、字符串处理,把典型输入输出写成用例,防回归。
- 库与 API 的行为契约测试:你发布的接口"说好返回什么",用测试锁死,防止后续改动破坏契约。
- 重构保护网:项目要重构/升级编译器/换第三方库时,先跑全量测试,绿了再动手,稳如老狗。
- 教学与刷题验证:学 C++ 时把每道题的边界条件写成 TEST_CASE,比 printf 大法科学得多。
- CI/CD 自动化:配合 CMake + CTest + GitHub Actions,每次提交自动跑测试,挂掉就拦下合并。
- 数据驱动测试(参数化):同一逻辑喂多组输入(边界值、随机值、边界附近值),用 Generators 一行搞定。
五、环境准备与安装(分步骤)
5.1 方案 A:v3 + CMake FetchContent(推荐,现代项目标配)
在你的 CMakeLists.txt 中加入:
cmake_minimum_required(VERSION 3.14) project(Catch2Demo CXX) # 开启 C++17(Catch2 v3 要求 C++14 以上,推荐直接用 17) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 从 GitHub 自动拉取 Catch2 源码(第一次联网,之后本地缓存) include(FetchContent) FetchContent_Declare( Catch2 GIT_REPOSITORY https://github.com/catchorg/Catch2.git GIT_TAG v3.5.0 # 建议固定版本,避免升级带来行为变化 ) FetchContent_MakeAvailable(Catch2) # 你的测试可执行文件 add_executable(my_tests test_main.cpp) # Catch2WithMain 自带 main 函数,我们不用自己写 main target_link_libraries(my_tests PRIVATE Catch2::Catch2WithMain) # 把测试注册到 CTest,之后可用 ctest 一键跑 include(CTest) include(Catch) catch_discover_tests(my_tests)然后在项目根目录执行:
cmake -B build # 生成构建系统 cmake --build build # 编译 ctest --test-dir build # 运行所有测试(CTest 会逐个调用注册的用例)5.2 方案 B:v2 单头文件(5 分钟极速体验)
Catch2 v2 只需一个头文件 catch.hpp(约 1MB),把它和你的测试文件放一起:
// test_main.cpp #define CATCH_CONFIG_MAIN // ⚠️ 关键:这个宏必须放在 include 之前,让 Catch2 生成 main 函数 #include "catch.hpp" // v2 单头文件 TEST_CASE("第一个测试", "[demo]") { REQUIRE(1 + 1 == 2); }编译:
g++ -std=c++17 test_main.cpp -o test_main ./test_main⚠️v2 与 v3 差异预警(最易踩坑):
- v2:单头文件 catch.hpp + #define CATCH_CONFIG_MAIN;v3:拆成多个模块,必须链接 Catch2::Catch2WithMain,不能再写 CATCH_CONFIG_MAIN。
- v3 头文件路径带 catch2/ 前缀:#include <catch2/catch_test_macros.hpp>。
- 本文 Demo 按 v3 编写;若你用的是 v2,把 include 换成 "catch.hpp" 即可,宏用法一致。
六、核心概念:TEST_CASE 与 SECTION
6.1 TEST_CASE:一道"大题"
类比:TEST_CASE 是一张试卷里的一道大题,括号里写题名,方括号里是标签(tag)——相当于"这道题属于哪个知识模块"。
TEST_CASE("加法应该正确", "[calculator][smoke]") { // 两道标签:calculator 表示属于计算器模块,smoke 表示冒烟测试 REQUIRE(1 + 1 == 2); }6.2 SECTION:大题里的小问
类比:SECTION 是同一道大题下的"小问"。神奇之处在于:每个 SECTION 都会从头开始执行整个 TEST_CASE 一次,所以每个小问的环境都是独立、干净的——这是 Catch2 替代"测试夹具(fixture)"的巧妙设计。
TEST_CASE("栈的 push 与 pop", "[stack]") { std::vector<int> v; // 每次执行小问前,都会新建一个空栈(干净环境) SECTION("push 之后 size 为 1") { v.push_back(42); REQUIRE(v.size() == 1); // ✅ } SECTION("push 再 pop 之后为空") { v.push_back(42); v.pop_back(); REQUIRE(v.empty()); // ✅ } }执行时等价于:先跑第一小问(从 std::vector<int> v; 开始),再从头跑第二小问。所以即使第一小问里 push_back 改了 v,也不会污染第二小问。
⚠️SECTION 使用红线:
- SECTION不能写在 for、while、if 等控制语句内部(v2/v3 都禁止),否则行为未定义,编译可能报错或产生诡异结果;
- 同一层级的 SECTION 名不能重复;
- 一个 SECTION 只能嵌套在 TEST_CASE 或另一个 SECTION 内部。
七、核心断言:REQUIRE 与 CHECK
7.1 两者区别(重点!)
类比:REQUIRE 是"高考一科挂科直接出局";CHECK 是"平时作业,这题错了先记下,继续做后面的题"。
| 宏 | 失败时行为 | 适用场景 |
| REQUIRE(expr) | 立即中止当前 TEST_CASE(但整个程序继续跑其他用例) | 前置条件不满足,后面没法继续测 |
| CHECK(expr) | 记下失败,继续执行当前用例后续代码 | 想一口气收集该用例内的所有问题 |
| REQUIRE_FALSE(expr) / CHECK_FALSE(expr) | 断言表达式为假 | 验证"不该发生"的事 |
7.2 失败原因自动展开
这是 Catch2 最惊艳的特性:断言失败时,自动把表达式的左右值展开,你不用手动拼错误信息。
TEST_CASE("失败信息示例", "[demo]") { int a = 3; int b = 5; REQUIRE(a == b); // 失败输出: // test_main.cpp:8: FAILED: // REQUIRE( a == b ) // with expansion: // 3 == 5 ← 左右值自动展开,一眼看到差异 }7.3 补充断言家族
#include <stdexcept> #include <string> #include <catch2/catch_test_macros.hpp> TEST_CASE("异常与字符串断言", "[demo]") { // 断言抛出指定类型的异常 REQUIRE_THROWS_AS(throw std::runtime_error("boom"), std::runtime_error); // 断言不抛异常 REQUIRE_NOTHROW(std::string("hello")); // 用 INFO 打印上下文:失败时这些日志会一起出现在报告里(类比:考试时在草稿纸写的过程) INFO("当前用户 id = " << 10086); REQUIRE(true); // 字符串包含匹配(Matchers,类比:不看整句,只看有没有关键词) std::string msg = "hello catch2 world"; REQUIRE_THAT(msg, Catch::Matchers::ContainsSubstring("catch2")); }⚠️ 浮点数不要直接 ==:0.1 + 0.2 != 0.3 是浮点精度问题。用 Catch::Approx(v2)或 Matchers 的 WithinRel/WithinAbs(v3):
#include <catch2/catch_approx.hpp> // v3 需要显式 include double r = 0.1 + 0.2; REQUIRE(r == Catch::Approx(0.3)); // ✅ 允许微小误差八、完整可运行 Demo(v3)
下面是一个可直接运行的完整示例:一个简易 BankAccount 类 + 完整测试文件。
8.1 被测类:bank_account.hpp
#pragma once #include <stdexcept> // 简易银行账户:存钱、取钱、查询余额 class BankAccount { public: explicit BankAccount(double initial) : balance_(initial) {} // 存款:金额必须大于 0 void deposit(double amount) { if (amount <= 0) { throw std::invalid_argument("存款金额必须为正数"); } balance_ += amount; } // 取款:金额必须大于 0,且不能透支 void withdraw(double amount) { if (amount <= 0) { throw std::invalid_argument("取款金额必须为正数"); } if (amount > balance_) { throw std::runtime_error("余额不足"); } balance_ -= amount; } double balance() const { return balance_; } private: double balance_; // 当前余额 };8.2 测试文件:test_bank.cpp
// test_bank.cpp —— 编译方式见下方"运行 Demo" #include <catch2/catch_test_macros.hpp> // TEST_CASE / SECTION / REQUIRE / CHECK 都在这里 #include <catch2/catch_approx.hpp> // 浮点近似比较 #include "bank_account.hpp" // 场景一:存款功能 TEST_CASE("存款功能", "[bank][smoke]") { SECTION("新账户初始余额为 0") { BankAccount acc(0.0); CHECK(acc.balance() == Catch::Approx(0.0)); // 用 Approx 比较浮点 } SECTION("存入 100 后余额为 100") { BankAccount acc(0.0); acc.deposit(100.0); CHECK(acc.balance() == Catch::Approx(100.0)); } SECTION("存入负数抛异常") { BankAccount acc(0.0); REQUIRE_THROWS_AS(acc.deposit(-5), std::invalid_argument); } } // 场景二:取款功能(用 SECTION 验证"用例内子场景独立") TEST_CASE("取款功能", "[bank]") { BankAccount acc(100.0); // 每次小问都会从 100 元重新开始,互不干扰 SECTION("取 30 后余额为 70") { acc.withdraw(30.0); REQUIRE(acc.balance() == Catch::Approx(70.0)); } SECTION("取款金额超过余额抛异常") { REQUIRE_THROWS_AS(acc.withdraw(999.0), std::runtime_error); } SECTION("取款后继续取款(连续操作)") { acc.withdraw(40.0); acc.withdraw(10.0); CHECK(acc.balance() == Catch::Approx(50.0)); } } // 场景三:故意演示 CHECK 与 REQUIRE 的差异 TEST_CASE("CHECK 与 REQUIRE 行为演示", "[demo]") { int a = 1, b = 2, c = 3; CHECK(a == b); // ❌ 记下失败,但继续往下走 CHECK(b == c); // ❌ 记下失败,继续走 REQUIRE(a == a); // ✅ 通过 // 若把上面任一行改成 REQUIRE 且失败,这里就不会执行了 CHECK(true); }8.3 运行 Demo(分步骤)
步骤 1:建工程,目录结构如下:
demo/ ├── CMakeLists.txt # 内容见 5.1 节(把 test_main.cpp 换成 test_bank.cpp 即可) ├── bank_account.hpp └── test_bank.cpp
步骤 2:配置并编译
cmake -B build cmake --build build步骤 3:直接运行测试程序
./build/test_bank.exe九、命令行参数详解(测试程序的"遥控器")
Catch2 生成的可执行文件自带一套强大的命令行参数。常用如下:
| 参数 | 作用 | 示例 |
| [tag] 或名字过滤 | 只跑匹配的用例(支持 * 通配) | ./test_bank.exe [bank] 只跑银行测试 |
| -s / --success | 连通过的断言也打印出来 | ./test_bank.exe -s |
| -c <section> / --section | 只跑某个 SECTION | ./test_bank.exe -c "存入 100 后余额为 100" |
| -r <reporter> | 切换输出格式:console / compact / junit / xml | ./test_bank.exe -r compact |
| --durations yes | 显示每个用例耗时(性能排查) | ./test_bank.exe --durations yes |
| -a / --abort | 遇到第一个失败就中止整个测试(CI 快速失败) | ./test_bank.exe -a |
| --list-tests | 列出所有用例名和标签 | ./test_bank.exe --list-tests |
| --rng-seed <n> | 固定随机种子(配合 --order rand 复现随机顺序下的失败) | ./test_bank.exe --order rand --rng-seed 42 |
| -h | 查看全部帮助 | ./test_bank.exe -h |
实战小技巧:调试某个具体用例时用 -s -c 组合,只看最小范围且把通过项也打出来,快速定位问题。
⚠️参数位置:过滤表达式(如 [bank])直接放在命令末尾作为位置参数;-c 指定的 section 名必须完整精确匹配(可多个 -c 叠加)。
十、进阶速览
10.1 BDD 风格:用"故事"写测试
BDD(行为驱动开发)把测试写成"场景-给定-当-那么",更贴近业务描述。Catch2 原生支持:
#include <catch2/catch_test_macros.hpp> SCENARIO("账户取款的故事", "[bank][bdd]") { GIVEN("一个余额为 100 的账户") { BankAccount acc(100.0); WHEN("我取出 30") { acc.withdraw(30.0); THEN("余额应该是 70") { REQUIRE(acc.balance() == Catch::Approx(70.0)); } AND_THEN("再取 20 后余额为 50") { acc.withdraw(20.0); REQUIRE(acc.balance() == Catch::Approx(50.0)); } } } }输出会以缩进的"故事大纲"呈现,非技术同事也能看懂测试在测什么。
10.2 TEMPLATE_TEST_CASE:一份代码测多种类型
想让同一组断言跑遍 int / double / float?模板测试用例来帮忙:
#include <catch2/catch_template_test_macros.hpp> // 模板测试专用头文件 TEMPLATE_TEST_CASE("不同类型都能相加", "[template]", int, double, long) { TestType a = 3; // TestType 依次被替换为 int、double、long TestType b = 4; REQUIRE(a + b == TestType(7)); }它会自动生成 3 个用例(int / double / long 各一个),报告里用 <int>、<double> 区分。
10.3 Generators:数据驱动测试
不用手写 for 循环,用 GENERATE 批量喂数据,每个数据点都是一个独立用例(可单独筛选、独立报告失败):
#include <catch2/generators/catch_generators.hpp> TEST_CASE("平方函数", "[gen]") { auto x = GENERATE(1, 2, 3, 10); // 依次取 1,2,3,10 auto expect = GENERATE(1, 4, 9, 100); // 依次取 1,4,9,100(与上面同步配对) REQUIRE(x * x == expect); // 生成 4 个独立用例 } TEST_CASE("范围与随机生成", "[gen]") { auto n = GENERATE(range(1, 5)); // 1,2,3,4 auto r = GENERATE(take(10, random(0, 100))); // 取 10 个随机数(可用 --rng-seed 复现) CHECK(n > 0); CHECK(r >= 0 && r <= 100); }⚠️配对注意:多个 GENERATE 在同一作用域会按笛卡尔积/同步配对规则展开,数量不一致时要小心,建议保持一一对应或用 std::tie 绑定。
10.4 与 CMake 集成(回到 5.1)
catch_discover_tests(my_tests) 会把每个 TEST_CASE 单独注册成一个 CTest 用例,好处:
ctest --test-dir build -N # 列出所有注册的测试 ctest --test-dir build -R "取款功能" # 按正则只跑某用例 ctest --test-dir build --output-on-failure # 只显示失败详情(CI 最爱)这样在 GitHub Actions / GitLab CI 里,测试结果可以逐条展示,一目了然。
十一、常见问题速查表(FAQ)
| 问题 | 答案 |
| v2 和 v3 有什么区别?我用哪个? | v2 单头文件 catch.hpp + CATCH_CONFIG_MAIN,上手最快;v3 模块化、需链接 Catch2::Catch2WithMain、头文件带 catch2/ 前缀,是当前主线。新项目推荐 v3。 |
| 为什么我 #include "catch.hpp" 报找不到文件? | 说明你下的是 v3,头文件是 catch2/catch_test_macros.hpp;或 v2 的 catch.hpp 没放到 include 路径里。 |
| 编译报错 error: 'main' must return 'int'? | 漏了 CATCH_CONFIG_MAIN(v2)或没链接 Catch2::Catch2WithMain(v3),导致没有 main 入口。 |
| REQUIRE(a == b) 失败后下面的代码还跑吗? | 不跑。当前 TEST_CASE 立即中止,但其他用例照常执行;想继续跑用 CHECK。 |
| 浮点比较总失败怎么办? | 不要用 ==,用 Catch::Approx(x)(v3 记得 #include <catch2/catch_approx.hpp>)或 Matchers 的 WithinRel。 |
| 我只想跑某一个测试怎么操作? | ./test_bank.exe "测试名" 按名字过滤,或 -c "SECTION名" 过滤小问,[tag] 按标签过滤。 |
| 测试用例之间会互相干扰吗? | 不会。每个 TEST_CASE / SECTION 都是全新执行环境;若需要共享资源,用 static 或 fixture 要非常谨慎。 |
| 为什么我的 SECTION 编译不过/行为诡异? | 大概率把 SECTION 写进了 for/if 等控制语句里——这是禁止的,必须放在顶层或另一个 SECTION 内。 |
| 如何接入 CI? | CMake 里 include(CTest) + include(Catch) + catch_discover_tests(...),CI 中跑 ctest --output-on-failure。 |
| 生成的测试报告能进 Jenkins 吗? | 可以,用 -r junit 输出 JUnit XML,Jenkins/GitLab 原生识别。 |
| 测试偶发失败想复现? | 用 --order rand --rng-seed <数字>,记下种子号即可复现同一随机序列。 |
| 想测"抛出异常"怎么写? | REQUIRE_THROWS_AS(expr, std::runtime_error);不抛异常用 REQUIRE_NOTHROW(expr)。 |
十二、总结
Catch2 让你把"写测试"的成本降到最低:
- 三个宏起步:TEST_CASE + REQUIRE + CHECK 就能建立安全网;
- SECTION 自动隔离,天然解决"每个用例独立环境"的难题;
- 失败信息自动展开,省去手拼日志;
- 命令行参数 + CMake/CTest 集成,从本地调试到 CI 一键打通;
- BDD / 模板测试 / Generators满足进阶数据驱动需求。
把测试当作代码的"安全网"而不是"额外负担"——从这个 Demo 开始,给下一个类补上第一张安全网吧。