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::vectorThrowingObject 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_castint(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_ptrNetworkClient client_; public: FileUploader(std::unique_ptrNetworkClient 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_uniqueMockNetworkClient(); 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在异常测试方面的特点有助于你在不同项目间做出选择或进行迁移。特性doctestGoogle Test (gtest)Catch2基础异常断言CHECK_THROWS,REQUIRE_THROWSEXPECT_THROW,ASSERT_THROWREQUIRE_THROWS,CHECK_THROWS异常类型断言CHECK_THROWS_AS,REQUIRE_THROWS_ASEXPECT_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_NOTHROWEXPECT_NO_THROW,ASSERT_NO_THROWREQUIRE_NOTHROW,CHECK_NOTHROW匹配器支持支持Contains,Equals,Matches(正则) 等支持丰富的匹配器但需与EXPECT_THROW结合使用强大的匹配器系统与异常断言自然集成语法简洁性非常简洁宏名直观直观宏名统一非常简洁与doctest类似头部文件单头文件集成简单需要编译链接库单头文件或编译库 个人体会doctest在异常测试的语法上和Catch2一脉相承都非常直观和强大特别是对异常信息的灵活匹配。相比于Google Test它的语法更简洁统一CHECK_*/REQUIRE_*。对于新项目如果追求极简的集成和清晰的语法doctest是非常好的选择。对于大型历史项目如果已经在使用Google Test其异常测试功能也完全足够只是信息检查需要多写一点代码。选择哪个框架更多取决于项目整体对测试框架的偏好、集成复杂度和性能要求。
C++异常测试全攻略:doctest框架实战与最佳实践
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::vectorThrowingObject 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_castint(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_ptrNetworkClient client_; public: FileUploader(std::unique_ptrNetworkClient 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_uniqueMockNetworkClient(); 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在异常测试方面的特点有助于你在不同项目间做出选择或进行迁移。特性doctestGoogle Test (gtest)Catch2基础异常断言CHECK_THROWS,REQUIRE_THROWSEXPECT_THROW,ASSERT_THROWREQUIRE_THROWS,CHECK_THROWS异常类型断言CHECK_THROWS_AS,REQUIRE_THROWS_ASEXPECT_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_NOTHROWEXPECT_NO_THROW,ASSERT_NO_THROWREQUIRE_NOTHROW,CHECK_NOTHROW匹配器支持支持Contains,Equals,Matches(正则) 等支持丰富的匹配器但需与EXPECT_THROW结合使用强大的匹配器系统与异常断言自然集成语法简洁性非常简洁宏名直观直观宏名统一非常简洁与doctest类似头部文件单头文件集成简单需要编译链接库单头文件或编译库 个人体会doctest在异常测试的语法上和Catch2一脉相承都非常直观和强大特别是对异常信息的灵活匹配。相比于Google Test它的语法更简洁统一CHECK_*/REQUIRE_*。对于新项目如果追求极简的集成和清晰的语法doctest是非常好的选择。对于大型历史项目如果已经在使用Google Test其异常测试功能也完全足够只是信息检查需要多写一点代码。选择哪个框架更多取决于项目整体对测试框架的偏好、集成复杂度和性能要求。