Catch2 入门实战教程:从第一个测试用例到 Sections 与 BDD 测试
【免费下载链接】Catch2A modern, C++-native, test framework for unit-tests, TDD and BDD - using C++14, C++17 and later (C++11 support is in v2.x branch, and C++03 on the Catch1.x branch)项目地址: https://gitcode.com/GitHub_Trending/ca/Catch2
本篇教程面向刚接触 Catch2 的 C++ 开发者,以 Catch2 官方入门文档为主线,带你完成从安装、编写第一个TEST_CASE与REQUIRE断言,到掌握 Sections 共享前置代码、BDD 风格测试以及类型/数据驱动测试的完整实践路径。读完本文,你将能独立搭建一个基于 CMake 的 Catch2 测试工程,并写出结构清晰、可维护的单元测试与行为测试。
获取 Catch2:三种使用方式
在动手写测试之前,首先要解决如何把 Catch2 引入项目的问题。Catch2 提供多种集成方式,理想情况下应当优先通过其CMake 集成来使用,具体步骤参见 docs/cmake-integration.md。
除此之外,Catch2 还提供以下两种方式:
- pkg-config 文件:仓库根目录下提供 CMake/catch2.pc.in 与 CMake/catch2-with-main.pc.in 两个模板,安装后可直接通过
pkg-config --cflags --libs catch2获取编译与链接参数。 - 两个文件(header + cpp)分发:仓库 extras/ 目录下的
catch_amalgamated.hpp与catch_amalgamated.cpp是合并后的单头文件/单源文件版本。若使用这种分发方式,请记得把文档示例中的头文件替换为catch_amalgamated.hpp,详细步骤可参考 docs/migrate-v2-to-v3.md。
本教程后续示例默认假设你正在使用 CMake 集成。
编写第一个测试
一个真实的例子:阶乘函数
假设你编写了一个计算阶乘的函数,现在想验证它的正确性(此处先不讨论 TDD):
unsigned int Factorial( unsigned int number ) { return number <= 1 ? number : Factorial(number-1)*number; }对应的测试代码如下(完整可运行示例见 examples/010-TestCase.cpp):
#include <catch2/catch_test_macros.hpp> unsigned int Factorial( unsigned int number ) { return number <= 1 ? number : Factorial(number-1)*number; } TEST_CASE( "Factorials are computed", "[factorial]" ) { REQUIRE( Factorial(1) == 1 ); REQUIRE( Factorial(2) == 2 ); REQUIRE( Factorial(3) == 6 ); REQUIRE( Factorial(10) == 3628800 ); }这段代码会编译成一个完整的可执行程序,它能够响应各种命令行参数。如果直接不带任何参数运行,它会执行所有测试用例(此处只有一个),报告任何失败,输出通过/失败用例的统计摘要,并把失败用例的数量作为进程返回值返回——这在只需要一个"到底跑没跑通"的布尔答案时非常有用。
让测试失败,暴露真实 bug
上面的断言都能通过,但函数其实有个 bug:Factorial(0)按数学定义应当返回1。把它补进测试用例:
TEST_CASE( "Factorials are computed", "[factorial]" ) { REQUIRE( Factorial(0) == 1 ); REQUIRE( Factorial(1) == 1 ); REQUIRE( Factorial(2) == 2 ); REQUIRE( Factorial(3) == 6 ); REQUIRE( Factorial(10) == 3628800 ); }再次编译并运行,就会看到失败输出,形如:
Example.cpp:9: FAILED: REQUIRE( Factorial(0) == 1 ) with expansion: 0 == 1注意失败信息同时包含原始表达式REQUIRE( Factorial(0) == 1 )和函数实际返回的值0。这正是 Catch2 表达式分解机制的价值——不需要你手动拼错误消息。修复函数:
unsigned int Factorial( unsigned int number ) { return number > 1 ? Factorial(number-1)*number : 1; }这个例子说明了什么?
虽然这只是个简单测试,但它已经展示了 Catch2 的若干核心机制:
TEST_CASE宏引入测试用例。它接受一个或两个字符串参数——一个自由格式的测试名,以及(可选的)一个或多个标签。标签的详细规则见下文"测试用例与 Sections"一节。- 测试自动注册到测试运行器。你不需要做任何额外事情来让测试框架发现它。当然,你也可以通过命令行运行指定的单个测试或一组测试。
- 单个断言用
REQUIRE宏书写。它接受一个布尔表达式,并在内部借助表达式模板将其分解,从而在失败时能够对表达式的各个部分分别进行字符串化输出。
关于最后一点,需要注意:并非所有有用的检查都能表达成简单的布尔表达式,因此 Catch2 提供了更多断言宏。例如,检查表达式是否抛出异常用REQUIRE_THROWS宏。这些宏都定义在 src/catch2/catch_test_macros.hpp 中,下表列出最常用的几个及其行为:
| 宏 | 行为 |
|---|---|
REQUIRE(expr) | 断言成立,失败则立即中止当前测试用例 |
CHECK(expr) | 断言成立,失败不中止,继续执行后续断言 |
REQUIRE_FALSE(expr) | 断言表达式为假,失败则立即中止 |
CHECK_FALSE(expr) | 断言表达式为假,失败后继续执行 |
REQUIRE_THROWS(expr) | 要求表达式抛出任意异常 |
REQUIRE_NOTHROW(expr) | 要求表达式不抛异常 |
CHECK_THROWS(expr)/CHECK_NOTHROW(expr) | 与上对应的"继续执行"变体 |
WARN(expr) | 只记录警告,不影响测试结果 |
REQUIRE与CHECK的区别在 examples/030-Asn-Require-Check.cpp 中有直观演示:REQUIRE在第一次失败后停止(其后的断言不再执行),而CHECK会在失败后继续运行,从而一次性收集尽可能多的失败信息。
底层原理:表达式分解(Decomposition)
为什么REQUIRE( Factorial(0) == 1 )能同时打印出原始表达式和0 == 1?秘密在 src/catch2/internal/catch_decomposer.hpp 的实现中。其头文件注释说明:
我们把
REQUIRE( a == b )改写成Decomposer{} <= a == b,从而先求值Decomposer{} <= a,再让自定义运算符接管a == b的求值,把a转换成ExprLhs<T&>,进而转换成BinaryExpr<T&, U&>。
同时,值的字符串化由 src/catch2/catch_tostring.hpp 中的StringMaker完成:默认优先使用operator<<流式输出,对枚举、异常、STL 容器等类型还有专门的StringMaker特化。这也是失败信息能直接显示"1" == "x"这类可读内容的原因。
测试用例与 Sections
绝大多数测试框架都支持基于类的 fixture 机制——测试是类的方法,setup/teardown 在构造/析构函数中完成。Catch2 也支持这种方式,但在 idiomatic 的 Catch2 测试中,更常见的是用Sections在测试代码之间共享 setup 与 teardown。这一点通过下面的例子最能说明白(完整代码见 examples/100-Fix-Section.cpp):
TEST_CASE( "vectors can be sized and resized", "[vector]" ) { // This setup will be done 4 times in total, once for each section std::vector<int> v( 5 ); REQUIRE( v.size() == 5 ); REQUIRE( v.capacity() >= 5 ); SECTION( "resizing bigger changes size and capacity" ) { v.resize( 10 ); REQUIRE( v.size() == 10 ); REQUIRE( v.capacity() >= 10 ); } SECTION( "resizing smaller changes size but not capacity" ) { v.resize( 0 ); REQUIRE( v.size() == 0 ); REQUIRE( v.capacity() >= 5 ); } SECTION( "reserving bigger changes capacity but not size" ) { v.reserve( 10 ); REQUIRE( v.size() == 5 ); REQUIRE( v.capacity() >= 10 ); } SECTION( "reserving smaller does not change size or capacity" ) { v.reserve( 0 ); REQUIRE( v.size() == 5 ); REQUIRE( v.capacity() >= 5 ); } }Sections 的执行模型
对于每一个SECTION,整个TEST_CASE都会从头开始执行。这意味着每个 section 进入时拿到的都是一个全新的v——我们确信它有 size 5、capacity 至少 5,因为这两个断言在进入 section 之前也都会被检查。每一次运行只会执行一个叶子 section(如上例,setup 代码总共会执行 4 次)。如果测试的 setup 非常昂贵,这种模型可能不是最理想的,但换来的是每个 section 完全隔离、互不干扰,这通常正是单元测试想要的。
嵌套 Sections
Section 也可以嵌套。此时父 section 会为每个叶子 section 被多次进入。嵌套 section 最适合"多个测试共享一部分 setup"的场景。继续向量例子,我们可以这样检查std::vector::reserve不会移除多余的容量:
SECTION( "reserving bigger changes capacity but not size" ) { v.reserve( 10 ); REQUIRE( v.size() == 5 ); REQUIRE( v.capacity() >= 10 ); SECTION( "reserving down unused capacity does not change capacity" ) { v.reserve( 7 ); REQUIRE( v.size() == 5 ); REQUIRE( v.capacity() >= 10 ); } }另一种理解方式是:Sections 定义了穿过测试的一条路径树。每个 section 是一个节点,整棵树以深度优先方式遍历,每条路径只访问一个叶子节点。
关于嵌套深度,没有实际的数量限制——只要你的编译器受得了。但要记住,过度嵌套的 section 会变得难以阅读。经验表明,嵌套超过 3 层的 section 通常已经很难跟进,而且不值得用它换来那点去重。
底层机制:SECTION 宏如何工作
从源码 src/catch2/internal/catch_section.hpp 可以看到,SECTION宏实际展开为一个依赖if的守卫:
# define INTERNAL_CATCH_SECTION( ... ) \ CATCH_INTERNAL_START_WARNINGS_SUPPRESSION \ CATCH_INTERNAL_SUPPRESS_UNUSED_VARIABLE_WARNINGS \ if ( Catch::Section const& INTERNAL_CATCH_UNIQUE_NAME( \ catch_internal_Section ) = \ Catch::Section( CATCH_INTERNAL_LINEINFO, __VA_ARGS__ ) ) \ CATCH_INTERNAL_STOP_WARNINGS_SUPPRESSION即构造一个Catch::Section临时对象,其operator bool()决定该 section 本次是否应被执行,而 section 的选择与追踪由catch_test_case_tracker模块(src/catch2/internal/catch_test_case_tracker.cpp)在运行期记录"已执行过的叶子路径",从而保证每次运行恰好执行一条完整叶子路径。这也是嵌套 section 能够按深度优先逐条展开的根本原因。
测试用例的注册与元信息
TEST_CASE声明的测试会通过静态注册进入运行器。从 src/catch2/catch_test_case_info.hpp 的TestCaseInfo结构可以看到,测试用例由(类)名字 + 标签组合唯一标识,源码位置不参与标识;标签在内部保持排序存储,并且带有隐藏(IsHidden)、应当失败(ShouldFail)、可能失败(MayFail)、抛异常(Throws)、非可移植(NonPortable)等属性位。标签的完整说明可参考 docs/test-cases-and-sections.md,其中几个值得一提的特殊标签:
[.]:将测试用例从默认运行列表中隐藏(常与用户标签组合,如[.][integration],或直接用[.integration]简写)。[!throws]:告知 Catch2 该测试可能抛异常,使其在使用-e/--nothrow运行时被排除。[!mayfail]:断言失败不判测试失败(仍会报告),适合标记进行中的工作或已知问题。[!shouldfail]:与[!mayfail]相反——测试通过反而判失败,用于捕捉意外的"修复"。[!nonportable]:表示行为可能随平台/编译器而变。[@alias]:标签别名,可通过CATCH_REGISTER_TAG_ALIAS注册,例如CATCH_REGISTER_TAG_ALIAS( "[@nhf]", "[failing]~[.]" )。
此外,CATCH_CONFIG_PREFIX_ALL与CATCH_CONFIG_DISABLE两个编译期开关可以分别让所有宏带上CATCH_前缀、或把测试宏全部替换为空操作(详见 src/catch2/catch_test_macros.hpp 中的四种配置分支)。
BDD 风格测试
Catch2 还提供对BDD 风格测试的基本支持:有一组TEST_CASE和SECTION的宏别名,让测试读起来像 BDD 规格说明。
SCENARIO充当TEST_CASE,区别是测试名会带上 "Scenario: " 前缀(源码中正是#define SCENARIO( ... ) TEST_CASE( "Scenario: " __VA_ARGS__ ))。GIVEN、WHEN、THEN(以及带AND_前缀的变体AND_GIVEN/AND_WHEN/AND_THEN)充当SECTION,只是 section 名分别以对应的宏名作为前缀。从 src/catch2/catch_test_macros.hpp 可看到它们实际通过INTERNAL_CATCH_DYNAMIC_SECTION实现。
来看用 BDD 宏重写向量例子的效果(完整代码见 examples/120-Bdd-ScenarioGivenWhenThen.cpp):
SCENARIO( "vectors can be sized and resized", "[vector]" ) { GIVEN( "A vector with some items" ) { std::vector<int> v( 5 ); REQUIRE( v.size() == 5 ); REQUIRE( v.capacity() >= 5 ); WHEN( "the size is increased" ) { v.resize( 10 ); THEN( "the size and capacity change" ) { REQUIRE( v.size() == 10 ); REQUIRE( v.capacity() >= 10 ); } } WHEN( "the size is reduced" ) { v.resize( 0 ); THEN( "the size changes but not capacity" ) { REQUIRE( v.size() == 0 ); REQUIRE( v.capacity() >= 5 ); } } WHEN( "more capacity is reserved" ) { v.reserve( 10 ); THEN( "the capacity changes but not the size" ) { REQUIRE( v.size() == 5 ); REQUIRE( v.capacity() >= 10 ); } } WHEN( "less capacity is reserved" ) { v.reserve( 0 ); THEN( "neither size nor capacity are changed" ) { REQUIRE( v.size() == 5 ); REQUIRE( v.capacity() >= 5 ); } } } }语义上,一个GIVEN子句内部可以包含多个相互独立的WHEN子句,从而只初始化一次对象就完成多组子测试;而当子句之间存在依赖(例如某个WHEN必须在前一个WHEN执行并验证之后才发生),则使用AND_WHEN/AND_THEN等宏进行链式拼接。GIVEN/WHEN/THEN天然对应 AAA(Arrange-Act-Assert / Assemble-Activate-Assert)测试模式,让"准备—动作—断言"三个阶段在代码中一目了然,无需额外注释。
这些宏的详细定义与使用约束可参考 docs/test-cases-and-sections.md 中的 BDD 章节。
数据驱动与类型驱动测试
Catch2 的测试用例还可以由类型、输入数据、或两者同时驱动:
- 类型参数化测试用例:针对多种类型分别实例化同一个测试模板,详见 docs/test-cases-and-sections.md。
- 数据生成器(Generators):为测试提供按需生成的数据流,详见 docs/generators.md。仓库中 examples/300-Gen-OwnGenerator.cpp、examples/301-Gen-MapTypeConversion.cpp、examples/302-Gen-Table.cpp 等示例覆盖了自定义生成器、类型转换生成器与表格驱动生成等场景。
下一步:继续深入
本页是一个快速入门,旨在让你把 Catch2 跑起来并认识其基本特性。文中提到的这些特性已经能支撑你走很远,但 Catch2 的能力远不止于此——例如命令行过滤、事件监听器、匹配器(Matchers)、基准测试等。你可以在阅读过程中按需查阅不断扩充的文档参考目录。
动手建议:先从 examples/ 目录下的示例起步(每个文件头部都附有 GCC/Clang 与 MSVC 的编译运行命令,例如g++ -std=c++14 -Wall -I$(CATCH_SINGLE_INCLUDE) -o 010-TestCase 010-TestCase.cpp && 010-TestCase --success),再用 CMake 把 Catch2 集成到自己的项目中,最后结合--success、--reporter compact等命令行选项观察输出差异,即可快速建立完整的实战手感。
【免费下载链接】Catch2A modern, C++-native, test framework for unit-tests, TDD and BDD - using C++14, C++17 and later (C++11 support is in v2.x branch, and C++03 on the Catch1.x branch)项目地址: https://gitcode.com/GitHub_Trending/ca/Catch2
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考