1. 项目概述:为什么我们需要专门测试异常?
在C++项目里摸爬滚打久了,你会发现一个挺有意思的现象:大家写单元测试,往往都盯着“正常路径”使劲测。一个函数,输入1、2,期望输出3,测!输入边界值,测!但要是问“这个函数在参数非法时会不会按设计抛出异常?抛出的异常信息对不对?”,很多人可能就含糊了,或者干脆写个简单的REQUIRE_THROWS就完事。这其实留下了一个巨大的测试盲区——异常处理路径。
异常,本身就是程序在遇到预期外或错误情况时,用于跳出正常控制流的一种机制。如果这条“逃生通道”本身没经过充分测试,那它的可靠性就存疑。想象一下,一个负责文件解析的模块,当文件格式错误时理应抛出std::runtime_error。但如果因为代码改动,它默默地返回了一个默认值,或者抛出了一个完全不同的异常类型,而你的测试集没发现,那下游模块就可能以错误的状态继续运行,导致更隐蔽、更难排查的Bug。
这就是doctest的异常测试能力显得尤为重要的原因。doctest是一个轻量级、功能齐全的C++单头文件测试框架,它提供了一组直观且强大的断言宏,专门用于验证代码是否按预期抛出(或不抛出)异常。它不仅仅是检查“有没有异常”,更能深入到检查“抛出的是什么异常”、“异常信息是什么”。对于构建健壮、可维护的C++代码库来说,系统性地测试异常场景,是和测试正常功能同等重要的一环。本文将深入拆解doctest的异常处理测试机制,从基础用法到高阶技巧,并结合实际场景,让你能彻底掌握如何为你的C++异常逻辑编写可靠的测试。
2. doctest异常断言宏全解析
doctest提供了几个核心宏来处理异常测试,它们的设计意图清晰,覆盖了不同的测试需求。
2.1 基础异常断言:CHECK_THROWS与REQUIRE_THROWS
这是最常用的入门级宏。它们用于验证一段代码(通常是一个函数调用或一个表达式)是否会抛出任何类型的异常。
TEST_CASE("测试基础异常抛出") { auto faulty_divide = [](int a, int b) -> int { if (b == 0) { throw std::invalid_argument("除数不能为零"); } return a / b; }; SUBCASE("除数为零应抛异常") { CHECK_THROWS(faulty_divide(10, 0)); // 检查是否会抛出异常 REQUIRE_THROWS(faulty_divide(10, 0)); // 检查是否会抛出异常,失败则终止当前测试用例 } SUBCASE("正常除法不应抛异常") { CHECK_NOTHROW(faulty_divide(10, 2)); // 检查是否不会抛出异常 } }CHECK_THROWSvsREQUIRE_THROWS: 这和doctest中其他CHECK_*与REQUIRE_*宏的区别一致。CHECK_*在断言失败时,测试会标记为失败但继续执行后续断言。REQUIRE_*则更为严格,一旦失败,会立即终止当前测试用例(TEST_CASE或SUBCASE)的执行。通常,如果后续的测试逻辑严重依赖于前一个异常断言的成功(例如,依赖于异常抛出后某个对象的状态),那么使用REQUIRE_THROWS更合适。否则,使用CHECK_THROWS可以收集一个测试用例中的多个失败点。
> 注意:CHECK_THROWS(expr)中的expr是一个表达式。如果expr的结果类型是void,可以直接使用。如果它有非void的返回值,这个返回值会被简单地忽略。宏只关心执行过程是否抛出异常。
2.2 类型特异性断言:CHECK_THROWS_AS与REQUIRE_THROWS_AS
仅仅知道会抛异常还不够,我们经常需要确保抛出的是特定类型的异常。例如,参数错误应该抛std::invalid_argument,资源不足应该抛std::runtime_error。CHECK_THROWS_AS和REQUIRE_THROWS_AS就是用于此目的。
TEST_CASE("测试异常类型") { auto load_config = [](const std::string& path) -> Config { if (path.empty()) { throw std::invalid_argument("配置文件路径不能为空"); } if (!std::filesystem::exists(path)) { throw std::filesystem::filesystem_error( "文件不存在", std::make_error_code(std::errc::no_such_file_or_directory) ); } // ... 加载逻辑 return Config{}; }; SUBCASE("空路径抛出 invalid_argument") { CHECK_THROWS_AS(load_config(""), std::invalid_argument); // 也可以写成:REQUIRE_THROWS_AS(load_config(""), std::invalid_argument); } SUBCASE("不存在的文件抛出 filesystem_error") { CHECK_THROWS_AS(load_config("nonexistent.json"), std::filesystem::filesystem_error); } }关键点:CHECK_THROWS_AS(expr, exception_type)会检查expr抛出的异常是否能被exception_type类型的引用捕获。这意味着它也接受派生类异常。例如,如果expr抛出一个继承自std::runtime_error的自定义异常MyRuntimeError,那么CHECK_THROWS_AS(expr, std::runtime_error)也会通过测试。这符合C++异常捕获的多态性原则,在测试中非常有用。
2.3 异常信息匹配:CHECK_THROWS_WITH与REQUIRE_THROWS_WITH
异常的类型对了,但信息(what()返回的字符串)对吗?错误的描述信息会给调试带来很大困扰。CHECK_THROWS_WITH和REQUIRE_THROWS_WITH允许你验证抛出的异常信息是否包含特定的字符串。
TEST_CASE("测试异常信息") { auto validate_age = [](int age) { if (age < 0) { throw std::out_of_range("年龄不能为负数,当前值:" + std::to_string(age)); } if (age > 150) { throw std::out_of_range("年龄超出合理范围,当前值:" + std::to_string(age)); } }; SUBCASE("负数年龄信息匹配") { CHECK_THROWS_WITH(validate_age(-5), "年龄不能为负数"); // 这个断言会通过,因为抛出的异常信息包含子串“年龄不能为负数” } SUBCASE("精确匹配整个信息") { CHECK_THROWS_WITH(validate_age(-5), Contains("年龄不能为负数,当前值:-5")); // 使用doctest的匹配器Contains进行更灵活的匹配 } SUBCASE("使用正则表达式匹配") { CHECK_THROWS_WITH(validate_age(200), Matches("年龄超出合理范围,当前值:\\d+")); // 使用Matches匹配器,验证信息符合某个正则表达式模式 } }字符串匹配方式:默认情况下,CHECK_THROWS_WITH进行的是大小写敏感的子字符串查找。只要抛出的exception.what()字符串中包含指定的子串,断言即成功。这通常比完全相等匹配更灵活、更健壮,因为异常信息中可能包含动态内容(如变量值、行号)。
使用匹配器(Matchers)进行高级校验:doctest还支持通过Contains、Equals、Matches(正则表达式)等匹配器来进行更精确或更复杂的字符串验证。这极大地增强了异常信息测试的表达能力。
2.4 组合断言:CHECK_THROWS_MESSAGE与类型信息双重验证
有时我们需要同时验证异常的类型和信息。虽然可以写两个断言(一个CHECK_THROWS_AS,一个CHECK_THROWS_WITH),但doctest提供了更简洁的组合断言宏(在较新版本中,可能需要查看具体版本说明,但思想一致)。更常见的做法是,利用doctest的断言可以组合的特性,或者直接使用CHECK_THROWS_WITH配合特定异常类型(实际上,CHECK_THROWS_WITH本身不关心类型,只关心信息)。为了进行双重验证,我们可以这样做:
TEST_CASE("组合验证异常类型和信息") { auto complex_operation = [](int mode) { if (mode == 1) { throw MyDomainError("操作模式1无效"); } else if (mode == 2) { throw std::system_error(std::make_error_code(std::errc::io_error), "文件读写失败"); } }; SUBCASE("验证自定义异常类型和信息") { CHECK_THROWS_AS(complex_operation(1), MyDomainError); // 如果想知道信息,可以再捕获一次(但这不是最优方式) try { complex_operation(1); FAIL("Expected an exception!"); } catch (const MyDomainError& e) { CHECK(std::string(e.what()) == "操作模式1无效"); } } }> 实操心得:对于需要严格同时验证类型和信息的场景,上述“先断言类型,再手动捕获验证信息”的方法虽然稍显冗长,但非常清晰和直接。另一种模式是,如果你的测试用例逻辑允许,可以为一个测试场景编写两个单独的SUBCASE,分别验证类型和信息,这同样能保证覆盖,且结构更清晰。
2.5 否定断言:CHECK_NOTHROW与REQUIRE_NOTHROW
最后,别忘了测试那些不应该抛出异常的场景。这能确保你的函数在有效输入范围内是稳定和安全的。
TEST_CASE("测试无异常场景") { auto safe_operation = [](int input) { // 假设这个函数在设计上对任何输入都不抛异常 return input * 2; }; SUBCASE("有效输入不应抛异常") { CHECK_NOTHROW(safe_operation(42)); CHECK_NOTHROW(safe_operation(0)); CHECK_NOTHROW(safe_operation(-100)); // 如果safe_operation意外抛异常,这些断言会失败 } }使用CHECK_NOTHROW可以增强你对代码在正常路径下行为稳定性的信心。
3. 深入原理:doctest如何捕获和匹配异常?
了解这些宏背后的原理,能帮助你在遇到复杂情况时更好地调试测试。
异常捕获机制:本质上,CHECK_THROWS_*系列宏在底层会用一个try-catch(...)块(或更特化的catch块)包裹你传入的表达式expr。
- 执行:在
try块中执行expr。 - 捕获:如果
expr执行过程中抛出了异常,控制流会跳转到对应的catch块。 - 分析:在
catch块中,框架会检查捕获到的异常:- 对于
CHECK_THROWS:只要捕获到任何异常,即成功。 - 对于
CHECK_THROWS_AS:尝试用catch (exception_type& e)来重新抛出并捕获,成功则匹配。 - 对于
CHECK_THROWS_WITH:捕获异常后,调用e.what()获取字符串,并与预期字符串进行匹配。
- 对于
- 报告:如果异常行为符合断言预期,则测试通过。否则,测试失败,
doctest会输出详细的诊断信息,例如“预期抛出异常但未抛出”,或“预期异常类型为X,但实际抛出Y”,或“预期异常信息包含‘abc’,实际为‘xyz’”。
类型匹配与继承体系:如前所述,CHECK_THROWS_AS利用了C++的异常捕获多态性。其内部实现逻辑类似于:
bool threw_expected = false; try { expr; // 执行被测表达式 } catch (const ExpectedExceptionType&) { // 注意这里是const引用 threw_expected = true; } catch (...) { // 捕获到其他类型异常,不是我们想要的 } // 然后根据threw_expected判断断言成败因此,如果抛出的异常是ExpectedExceptionType的派生类,它也能被这个catch块捕获,断言成功。
字符串匹配的细节:CHECK_THROWS_WITH的默认子串匹配是大小写敏感的。这意味着CHECK_THROWS_WITH(func(), "error")不会匹配到异常信息“Error: something”。如果你需要大小写不敏感的匹配,或者更复杂的模式(如正则表达式),就必须使用doctest的匹配器(Contains、Equals、Matches等)。匹配器提供了更强大、更声明式的匹配方式。
4. 实战:测试复杂异常场景的策略与技巧
掌握了基本宏之后,我们来看如何在真实项目中系统性地测试异常。
4.1 测试异常安全保证
异常安全是C++中的一个重要概念,通常分为基本保证、强保证和不抛异常保证。我们可以用doctest来验证代码是否提供了其承诺的异常安全级别。
- 基本保证:测试在异常发生时,程序状态仍然是有效的,没有资源泄漏。这通常需要结合对对象状态或资源句柄的检查。
TEST_CASE("Vector::push_back 提供基本异常安全") { std::vector<ThrowingObject> vec; vec.reserve(2); // 预分配空间 vec.emplace_back(1); // 第一个元素成功 ThrowingObject::trigger_on_copy = true; // 让拷贝构造函数抛异常 // 尝试插入第二个元素,其拷贝构造会失败 REQUIRE_THROWS(vec.push_back(ThrowingObject(2))); // 基本保证:vec 仍然是一个有效对象 CHECK(vec.size() == 1); // 大小应回滚 CHECK(vec[0].id == 1); // 第一个元素应保持不变 // 还需要确保没有内存泄漏(通常需要借助工具如Valgrind) } - 强保证(事务性):测试操作要么完全成功,要么完全失败,状态回滚到操作前。这通常需要比较操作前后的状态快照。
TEST_CASE("DatabaseTransaction 提供强保证") { Database db; auto state_before = db.get_snapshot(); REQUIRE_THROWS(db.execute_transaction([](Database& d) { d.insert("A", 1); d.insert("B", 2); throw std::runtime_error("模拟失败"); })); auto state_after = db.get_snapshot(); CHECK(state_before == state_after); // 状态应完全回滚 } - 不抛异常保证(nothrow):直接用
CHECK_NOTHROW验证。TEST_CASE("std::swap 提供 nothrow 保证(对于内置类型)") { int a = 5, b = 10; CHECK_NOTHROW(std::swap(a, b)); }
4.2 测试自定义异常类
自定义异常类通常除了类型,还会携带额外的错误码、上下文信息等。测试它们需要更细致的方法。
class MyBusinessException : public std::runtime_error { public: enum class ErrorCode { InvalidInput, NetworkTimeout, ServerError }; MyBusinessException(ErrorCode code, const std::string& context) : std::runtime_error("Business error: " + std::to_string(static_cast<int>(code)) + " - " + context) , error_code_(code) , context_(context) {} ErrorCode get_error_code() const { return error_code_; } const std::string& get_context() const { return context_; } private: ErrorCode error_code_; std::string context_; }; TEST_CASE("测试自定义异常 MyBusinessException") { auto process_request = [](const Request& req) { if (req.data.empty()) { throw MyBusinessException(MyBusinessException::ErrorCode::InvalidInput, "请求数据为空"); } // ... 处理逻辑 }; Request empty_req; SUBCASE("验证异常类型和基类") { CHECK_THROWS_AS(process_request(empty_req), MyBusinessException); // 精确类型 CHECK_THROWS_AS(process_request(empty_req), std::runtime_error); // 基类类型,也应通过 } SUBCASE("验证异常信息和内部状态") { try { process_request(empty_req); FAIL("应抛出异常"); } catch (const MyBusinessException& e) { // 验证异常信息 CHECK_THROWS_WITH(throw e, Contains("Business error")); CHECK_THROWS_WITH(throw e, Contains("InvalidInput")); // 注意:枚举转字符串可能只是数字 // 更精确地验证内部状态 CHECK(e.get_error_code() == MyBusinessException::ErrorCode::InvalidInput); CHECK(e.get_context() == "请求数据为空"); } } }> 注意事项:测试自定义异常时,重点不仅是what()信息,更要测试其自定义的成员函数和状态,因为这些才是传递具体错误信息的载体。
4.3 模拟异常注入以测试异常处理路径
有时,你想测试的不是一个直接会抛异常的函数,而是一个调用了一系列操作、需要在中间某步失败时进行清理的函数。这时可以使用“测试替身”(如Mock对象)来注入异常。
// 假设有一个文件上传器,依赖一个网络客户端 class NetworkClient { public: virtual void send_data(const DataPacket& packet) = 0; virtual ~NetworkClient() = default; }; class FileUploader { std::unique_ptr<NetworkClient> client_; public: FileUploader(std::unique_ptr<NetworkClient> client) : client_(std::move(client)) {} bool upload(const std::string& filepath) { std::ifstream file(filepath); if (!file) return false; try { DataPacket packet; while (file >> packet) { client_->send_data(packet); // 可能抛异常 } return true; } catch (const std::exception& e) { // 异常处理路径:记录日志,清理临时状态等 cleanup_partial_upload(filepath); return false; } } private: void cleanup_partial_upload(const std::string& path) { /* ... */ } }; // 测试:模拟网络发送失败,验证清理逻辑 TEST_CASE("FileUploader 在发送失败时执行清理") { class MockNetworkClient : public NetworkClient { public: MOCK_METHOD(void, send_data, (const DataPacket&), (override)); }; auto mock_client = std::make_unique<MockNetworkClient>(); auto& mock_ref = *mock_client; // 设置期望:第一次调用成功,第二次调用抛异常 EXPECT_CALL(mock_ref, send_data) .WillOnce(Return()) .WillOnce(Throw(std::runtime_error("网络断开"))); FileUploader uploader(std::move(mock_client)); // 执行上传,预期会因异常而返回false CHECK_FALSE(uploader.upload("test.dat")); // 这里还可以添加断言,验证 cleanup_partial_upload 是否被调用 // 这可能需要将FileUploader的清理方法设为可观测(如虚函数、回调等),或检查副作用(如临时文件被删除) }这个例子使用了Google Mock框架来创建Mock对象并注入异常。核心思想是:通过控制依赖组件的行为,来触发被测代码的异常处理路径,从而验证其正确性。
5. 常见陷阱、调试技巧与最佳实践
即使熟悉了宏的使用,在实际编写异常测试时还是会踩一些坑。下面是一些常见问题和解决方案。
5.1 陷阱一:异常被意外捕获
如果你的测试代码,或者被测代码内部,有一个catch(...)块并且没有重新抛出(throw;),那么doctest的断言宏将无法检测到异常。
// 错误示例:被测函数吞掉了异常 void bad_function() { try { throw std::runtime_error("error"); } catch (...) { // 吞掉异常,什么都不做! std::cout << "异常被默默处理了" << std::endl; } } TEST_CASE("这个测试会失败") { CHECK_THROWS(bad_function()); // 断言失败!因为异常根本没传播出来 }排查方法:确保你的测试目标(函数或表达式)内部没有在顶层吞掉你需要测试的异常。如果函数设计就是如此(即异常是内部处理的),那么你就不应该测试它抛出异常,而应该测试其内部处理异常后的外部可见行为(如返回值、状态变化、日志输出等)。
5.2 陷阱二:异常信息动态内容导致测试脆弱
异常信息中如果包含动态内容(如文件名、行号、时间戳、指针地址等),直接使用CHECK_THROWS_WITH进行完全匹配会导致测试非常脆弱,容易因环境变化而失败。
std::string generate_error(int id) { // 错误信息包含动态内容 return "Error processing ID: " + std::to_string(id) + " at " + __FILE__ + ":" + std::to_string(__LINE__); } // 直接匹配会失败,因为 __FILE__ 和 __LINE__ 每次编译可能不同 // CHECK_THROWS_WITH(throw std::runtime_error(generate_error(42)), "Error processing ID: 42 at myfile.cpp:123");解决方案:
- 使用子串匹配:只匹配信息中稳定的部分。
CHECK_THROWS_WITH(throw std::runtime_error(generate_error(42)), Contains("Error processing ID: 42")); - 使用正则表达式匹配:匹配动态内容的模式。
CHECK_THROWS_WITH(throw std::runtime_error(generate_error(42)), Matches("Error processing ID: 42 at .*\\.cpp:\\d+")); - 重构异常生成逻辑:将动态内容与静态信息分离,使异常信息更易于测试。例如,可以提供一个
std::ostringstream来构建信息,或者使用固定的错误模板。
5.3 陷阱三:析构函数抛异常
如果异常正在传播过程中,某个局部对象的析构函数也抛出了异常,程序会直接调用std::terminate。这会导致你的测试以一种非常规方式崩溃,而不是被doctest优雅地捕获为测试失败。
class BadActor { public: ~BadActor() noexcept(false) { // 错误:析构函数不应抛异常 throw std::logic_error("析构也抛异常"); } }; void risky_call() { BadActor actor; throw std::runtime_error("主要异常"); } TEST_CASE("这可能导致程序终止,而非测试失败") { CHECK_THROWS(risky_call()); // 当主要异常抛出,actor析构时又抛异常,程序会terminate }最佳实践:遵循C++核心准则——析构函数、内存释放函数(operator delete)、swap函数等应标记为noexcept,确保它们绝不抛出异常。在测试中,要警惕那些可能违反此准则的第三方库或遗留代码。
5.4 调试技巧:当异常断言失败时
当CHECK_THROWS失败(即预期抛异常但没抛)时,doctest的输出相对简单。但当CHECK_THROWS_AS或CHECK_THROWS_WITH失败时,输出信息就非常关键。
- 类型不匹配:
doctest会输出类似“Expected exception of typestd::invalid_argumentbut gotstd::out_of_range”的信息。这时你需要检查为什么抛出的异常类型与预期不符。是不是条件判断逻辑错了?或者异常继承层次设计有问题? - 信息不匹配:
doctest会输出预期字符串和实际what()字符串。仔细对比差异,是拼写错误、标点符号(中英文)、空格还是动态内容导致的?使用Contains匹配器可以避免很多琐碎的差异。
一个有用的调试模式:在复杂的测试中,如果异常断言行为诡异,可以暂时将其替换为手动try-catch块,并在catch块中打印出异常的具体信息,甚至使用调试器设置断点。
TEST_CASE("调试异常") { try { some_complex_function_that_should_throw(); FAIL("Expected exception"); } catch (const std::exception& e) { std::cout << "[DEBUG] Caught exception: " << e.what() << std::endl; std::cout << "[DEBUG] Exception type: " << typeid(e).name() << std::endl; // 重新抛出,让doctest的宏也能捕获到(如果需要) throw; } // 或者,在手动检查后,再用宏断言 // CHECK_THROWS_WITH(some_complex_function_that_should_throw(), "expected message"); }5.5 最佳实践总结
- 为每个异常场景编写独立的测试用例或子用例:使用
SUBCASE来组织不同的异常触发条件(如空指针、非法参数、资源不足等),使测试意图清晰。 - 优先使用
REQUIRE_THROWS_*:如果一个测试用例后续的断言依赖于异常是否被正确抛出,使用REQUIRE版本可以避免在异常未抛出时执行无意义或错误的后续检查。 - 异常信息测试要灵活:多用
Contains和Matches匹配器,少用完全相等的字符串匹配,以提高测试的健壮性。 - 测试“不抛异常”的保证:对于标记为
noexcept或承诺不抛异常的函数,使用CHECK_NOTHROW进行验证。 - 结合状态验证:异常抛出后,程序的状态(如对象状态、资源释放、数据一致性)是否正确?在测试异常后,经常需要跟进来验证这些状态。
- 避免测试实现细节:测试应该关注行为(是否抛异常、抛什么异常),而不是内部实现。例如,不要测试异常是从函数里的第几行抛出的(除非这是接口契约的一部分)。
- 利用Fixture减少重复代码:如果多个测试用例需要相同的异常触发前置条件(如构造一个特定状态的对象),可以使用
doctest的TEST_FIXTURE或简单的辅助函数来设置。
6. 与其它测试框架的异常测试对比
了解doctest在异常测试方面的特点,有助于你在不同项目间做出选择或进行迁移。
| 特性 | doctest | Google Test (gtest) | Catch2 |
|---|---|---|---|
| 基础异常断言 | CHECK_THROWS,REQUIRE_THROWS | EXPECT_THROW,ASSERT_THROW | REQUIRE_THROWS,CHECK_THROWS |
| 异常类型断言 | CHECK_THROWS_AS,REQUIRE_THROWS_AS | EXPECT_THROW(expr, ExceptionType) | REQUIRE_THROWS_AS(expr, ExceptionType) |
| 异常信息断言 | CHECK_THROWS_WITH,REQUIRE_THROWS_WITH支持子串和匹配器 | EXPECT_THROW仅检查类型,需手动捕获检查信息,或使用EXPECT_THROW_MESSAGE(gtest 1.12+) | REQUIRE_THROWS_WITH(expr, Matcher)或REQUIRE_THROWS_MATCHES |
| 无异常断言 | CHECK_NOTHROW,REQUIRE_NOTHROW | EXPECT_NO_THROW,ASSERT_NO_THROW | REQUIRE_NOTHROW,CHECK_NOTHROW |
| 匹配器支持 | 支持Contains,Equals,Matches(正则) 等 | 支持丰富的匹配器,但需与EXPECT_THROW结合使用 | 强大的匹配器系统,与异常断言自然集成 |
| 语法简洁性 | 非常简洁,宏名直观 | 直观,宏名统一 | 非常简洁,与doctest类似 |
| 头部文件 | 单头文件,集成简单 | 需要编译链接库 | 单头文件或编译库 |
> 个人体会:doctest在异常测试的语法上和Catch2一脉相承,都非常直观和强大,特别是对异常信息的灵活匹配。相比于Google Test,它的语法更简洁统一(CHECK_*/REQUIRE_*)。对于新项目,如果追求极简的集成和清晰的语法,doctest是非常好的选择。对于大型历史项目,如果已经在使用Google Test,其异常测试功能也完全足够,只是信息检查需要多写一点代码。选择哪个框架,更多取决于项目整体对测试框架的偏好、集成复杂度和性能要求。