ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入gtest源码:从TEST宏到参数化测试的核心机制解析

深入gtest源码:从TEST宏到参数化测试的核心机制解析 简介gtest源码包包含Google Test框架的完整源码面向C开发者、测试框架研究者和有志于提升单元测试能力的工程师可用于剖析框架内部机制、改进自身测试实践。压缩包共180个文件、约1.07MB主体为70个cc实现文件与31个h头文件另有Python脚本、m4宏、Visual Studio工程文件等分别承担源码生成、构建配置与跨平台工程支持目录结构清晰便于定位。源码覆盖测试用例与测试点管理、丰富的断言体系、TEST_P参数化测试、命令行过滤、XML测试报告输出等模块通过TEST_F宏定义测试点EXPECT_EQ、EXPECT_THROW等断言可分别处理数值比较与异常场景INSTANTIATE_TEST_SUITE_P则实现了参数化复用。从main入口到UnitTest单例调度再到EXPECT_*/ASSERT_*等断言宏的实现都展示了Google C测试框架的优雅设计。目前已有522人学习适合有一定C基础、希望深入掌握gtest运行原理或准备二次开发的读者反复研读可加深对测试驱动开发TDD与行为驱动开发BDD理念的理解。1. 读 gtest 源码先认路目录、核心文件与版本差异1.1 源码目录怎么看三个目录、两个头文件拿到 googletest 源码后很多人第一反应是找个 README 从头读其实没必要。gtest 源码的正门在 googletest 目录下核心结构非常清晰我平时只重点盯几个文件。include/gtest/gtest.h对外主头文件断言宏、TEST 系列宏、Test 基类、TestInfo、UnitTest 的声明全在这。include/gtest/gtest-param-test.h参数化测试相关宏和类模板TEST_P、INSTANTIATE_TEST_SUITE_P 的声明。include/gtest/internal/gtest-port.h平台适配层线程、原子操作、文件路径、系统判断全在这层抽象。include/gtest/internal/gtest-internal.h内部注册函数、宏辅助结构MakeAndRegisterTestInfo 的声明也在这里。src/gtest.cc最核心的实现文件UnitTest、TestSuite、TestInfo、TestResult 的实现基本都在里面。src/gtest-internal-inl.h内部实现头文件UnitTestImpl、TestEventRepeater、默认监听器都在这里。读 gtest 源码最容易犯的错是想把每个文件从头看到尾。gtest 有大量处理极端编译环境的宏比如老编译器、无 RTTI、异常关闭等情况这些对理解测试框架本身帮助不大。第一遍读我建议只看gtest.h和gtest.cc这条主链路先把测试从“注册”到“执行”到“输出结果”的完整流程串起来。1.2 版本选型与阅读入口gtest 的源码版本差异很大尤其是 1.10 之后把TestCase改成了TestSuite相关内部类名也换了。网上不少文章写的是 1.8 时代的代码如果你照搬去看新版源码会对着TestCase找半天最后发现类名早就不存在了。我建议直接锁定一个稳定 tag 来读。目前源码中INSTANTIATE_TEST_CASE_P已经废弃正式名称是INSTANTIATE_TEST_SUITE_P内部类从TestCase全部更名为TestSuite。如果你用git clone拉的是最新主干里面还会夹杂新的 API 演进代码新手容易看晕。按我的习惯直接git checkout v1.14.0再开始读。找阅读入口也有技巧。源码中真正的主线是RUN_ALL_TESTS()这是一个宏展开后是UnitTest::GetInstance()-Run()。顺着Run()往下走你就能看到整个测试执行的生命周期。这条线理清楚后其他如断言机制、事件监听器、参数化测试都是挂在这条主线上的一节节车厢。1.3 gtest.h 到 gtest.cc 的主调用线用最小的例子来说一个空测试程序最终会经历这些步骤main()调用RUN_ALL_TESTS()。宏展开成UnitTest::GetInstance()-Run()。UnitTest::Run()内部拿到UnitTestImpl遍历所有TestSuite。每个TestSuite遍历自己名下的TestInfo。每个TestInfo调用内部工厂对象创建测试 fixture 实例。执行SetUp()、TestBody()、TearDown()。测试结果写入TestResult。监听器接收到各种事件回调决定如何输出内容。这一整套设计本质上是一个“注册中心 工厂 事件回调”的组合。你在gtest.cc里能看到Run()的实现基本就是这个骨架。有了这条主调用线后面读 TEST 宏、断言、监听器时你会不停地发现原来这些看似独立的功能最后都落回这条线上。2. TEST 宏展开测试用例是怎么“凭空”冒出来的2.1 TEST 宏到底把测试函数藏在了哪很多人第一次好奇 gtest 源码都是因为同一个问题我只写了TEST(MySuite, MyTest) { ... }没有注册没有调用测试怎么就被框架发现了答案在宏里。TEST宏一层层展开最终会展开成一个类和一个静态变量。把这段逻辑简化后大致长这样#define TEST(test_suite_name, test_name) \ class test_suite_name##_##test_name##_Test : public ::testing::Test { \ public: \ test_suite_name##_##test_name##_Test() default; \ private: \ void TestBody() override; \ }; \ class test_suite_name##_##test_name##_Test##_Register { ... }; \ void test_suite_name##_##test_name##_Test::TestBody()也就是说你写的TEST(MySuite, MyTest) { ... }本质上定义了一个名为MySuite_MyTest_Test的类而大括号里那坨代码其实是这个类的成员函数TestBody()的函数体。这个技巧非常巧妙。用户不需要知道类的存在只要在宏后面直接跟一个函数体宏就帮你把函数体“塞进”了继承自Test的派生类里。你写的是函数体编译器的视角里却是一个完整的类定义加成员函数实现。2.2 MakeAndRegisterTestInfo 与静态注册光有类还不能被框架发现。真正的注册动作来自类外部的静态变量初始化。源码中生成的代码大概是::testing::TestInfo* const test_info_ \ ::testing::internal::MakeAndRegisterTestInfo( MySuite, MyTest, nullptr, nullptr, ::testing::internal::CodeLocation(__FILE__, __LINE__), ::testing::internal::GetTestTypeId(), ::testing::internal::SuiteApiResolverMySuite_MyTest_Test::GetSetUpTearDownSuite(), new ::testing::internal::TestFactoryImplMySuite_MyTest_Test);注意这里定义的是全局静态变量初始化发生在main()之前。所以测试用例的注册不需要任何显式调用只要测试程序一启动所有 TEST 宏生成的注册代码就已经全部执行完。MakeAndRegisterTestInfo内部会找到全局唯一的UnitTestImpl在里面按测试套件名分组建好TestSuite和TestInfo对象并把工厂对象保存下来。这个工厂对象非常关键它负责后续真正创建测试实例相当于一个延迟到运行期才生效的“构造说明书”。明白了这一点你也就理解了为什么 gtest 的测试用例数量和顺序在运行时是确定的。它们不是动态发现的而是在程序启动阶段由静态初始化顺序决定的。2.3 RunTest 里 SetUp、TestBody、TearDown 的调用真相注册完成后真正执行测试的逻辑在gtest.cc的TestInfo::Run()里。这里做的事情可以概括为用之前保存的工厂对象创建测试实例然后调用三个关键方法。简化后的执行逻辑是调用test-SetUp()。调用test-TestBody()也就是你写的测试逻辑。调用test-TearDown()。这三个调用被包在异常处理中。如果TestBody()抛出异常gtest 会捕获并将异常信息记录到TestResult里测试程序不会直接崩溃。这一点在设计测试框架时很值得学习测试代码出问题框架本身不能跟着崩要把失败信息收集起来统一输出。源码里还有一个细节SetUp()和TearDown()的执行顺序是由Test::Run()控制的不是由用户控制的。所以无论你写多少个 TEST_F框架都能保证每个测试都独立执行一遍 SetUp/TearDown这正是 fixture 测试语义的基础。3. ASSERT 与 EXPECT 的分岔路返回值、return 与失败记录3.1 断言宏的通用外壳断言是 gtest 使用频率最高的功能而ASSERT_*和EXPECT_*的核心区别很多人只是背结论前者失败就终止当前测试后者失败继续跑。但要真正理解必须看宏的实现。以ASSERT_TRUE(condition)为例它最终展开为类似这样的结构if (const ::testing::AssertionResult gtest_ar_ AssertionResult(condition)) { // 空分支 } else { return GTEST_MESSAGE_(...); // 实际是一段记录失败并 return 的逻辑 }这里有两个关键设计。第一用if (const AssertionResult gtest_ar_ ...)把条件的真假和失败信息的构造绑定在一起后续失败处理可以直接读取这个对象里的详细内容。第二两个分支之间不需要用户写 else宏已经替你安排好了。源码中还有GTEST_AMBIGUOUS_ELSE_BLOCKER_这个宏它用来避免用户写if (...) ASSERT_TRUE(...); else ...时出现的悬挂 else 问题。这种小细节如果你只是使用断言一辈子感知不到但把它们读了你会对宏设计的边界情况更敏感。3.2 为什么 ASSERT 能“中断”测试而 EXPECT 不能ASSERT_*的失败分支里有return而EXPECT_*的失败分支里没有。一句话讲完但值得再往深挖一点。在 gtest 源码中ASSERT_*系列宏的失败处理是GTEST_FATAL_FAILURE_它展开后包含return;。因此只要断言失败控制流会立刻从当前测试函数中返回。EXPECT_*系列走的是GTEST_NONFATAL_FAILURE_它只负责构造一条TestPartResult并推入当前测试的结果列表随后继续执行后续代码。所以从控制流层面看ASSERT_*本质上是一个“带 return 的 if 判断”并不是什么特殊的异常机制。理解了这一点你就能推断出很多行为。比如在循环里使用ASSERT_*失败时退出的是整个测试函数而不是仅跳出循环如果你希望失败后继续检查其余循环项就得用EXPECT_*。3.3 在非 void 辅助函数里用 ASSERT 为什么编译失败这是个很经典的坑。你写了一个辅助函数希望在里面用ASSERT_TRUE做前置校验结果编译直接报错错误信息里还带着return;之类的字样。原因正是上面说的ASSERT_*的失败分支是return;而 C 不允许在一个非 void 返回类型的函数里执行裸return;。换句话说ASSERT_*只能在返回类型为void的函数里使用。这个限制不算不合理但确实让不少人在封装公共校验逻辑时头疼。我的经验是如果辅助函数就是为测试服务的直接把它声明成void内部用ASSERT_*即可。如果你确实需要一个返回值的辅助函数又要在内部做断言那只能用EXPECT_*或返回AssertionResult自己拼。每次在源码里看到这种设计我都会提醒自己宏不是函数它展开后是写在调用处的代码。理解宏展开后的代码长什么样很多“怪问题”其实都不怪。4. 事件监听器控制输出、扩展框架的钩子4.1 TestEventListener 的方法时机用到 gtest 的人一般都知道测试失败会打印一堆信息但很少有人关心这些信息是从哪来的。答案就是事件监听器机制。TestEventListener是 gtest 对测试过程所有关键节点的回调抽象。监听器的虚方法覆盖了测试完整生命周期OnTestProgramStart整个测试程序开始时触发。OnTestSuiteStart一个测试套件开始时触发。OnTestStart单个测试用例开始时触发。OnTestPartResult每次断言产生结果时触发这是最常用的事件。OnTestEnd单个测试用例结束时触发。OnTestSuiteEnd、OnTestProgramEnd对应结束阶段。这些回调的调用点散布在UnitTest::Run()、TestSuite::Run()、TestInfo::Run()里。读完源码你会发现测试执行的过程本质上是一连串事件的分发过程具体业务动作都是挂在事件处理器上的。这种设计解耦性很好想扩展行为时不需要改动核心执行逻辑。4.2 默认监听器与替换策略gtest 在程序启动时会默认创建两个监听器并挂到UnitTest::GetInstance()-listeners()上。一个是DefaultPrinter负责把测试结果打印到标准输出另一个是XmlUnitTestPrinter负责生成 XML 格式的测试报告供 CI 系统解析。如果你只是想额外加点东西用listeners().Append()追加一个监听器即可。但要注意默认打印器还在你追加的内容是叠加上去的。如果你想完全自定义输出格式不想要默认那套终端打印需要先把默认监听器移除。移除方法不复杂但容易踩坑testing::UnitTest::GetInstance()-listeners().Release( testing::UnitTest::GetInstance()-listeners().default_result_printer());default_result_printer()返回的是默认监听器的指针Release()会把它从列表中摘下但不 delete这个对象由 gtest 自己管理你不用理会。经常有人直接delete这个指针导致后续崩掉这是误操作。4.3 一个自定义 JSON 输出监听器的实际写法理解了监听器机制后扩展起来其实很直接。比如我想输出一份 JSON 格式的结果只需要继承TestEventListener把关心的几个事件重写掉。class JsonListener : public testing::TestEventListener { public: void OnTestProgramStart(const testing::UnitTest) override {} void OnTestIterationStart(const testing::UnitTest, int) override {} void OnEnvironmentsSetUpStart(const testing::UnitTest) override {} void OnEnvironmentsSetUpEnd(const testing::UnitTest) override {} void OnEnvironmentsTearDownStart(const testing::UnitTest) override {} void OnEnvironmentsTearDownEnd(const testing::UnitTest) override {} void OnTestStart(const testing::TestInfo test_info) override {} void OnTestPartResult(const testing::TestPartResult result) override { if (result.failed()) { printf({\file\:\%s\,\line\:%d,\message\:\%s\}\n, result.file_name(), result.line_number(), result.message()); } } void OnTestEnd(const testing::TestInfo test_info) override { if (test_info.result()-Failed()) { printf({\test\:\%s.%s\,\status\:\failed\}\n, test_info.test_suite_name(), test_info.name()); } } void OnTestSuiteStart(const testing::TestSuite) override {} void OnTestSuiteEnd(const testing::TestSuite) override {} void OnTestProgramEnd(const testing::UnitTest) override {} };这里有一个我自己踩过的坑OnTestPartResult的输出最好不要用std::cout而用printf直接写标准输出。因为 gtest 默认监听器在输出测试进度时会带上自己的格式混杂std::cout的流式缓存可能打乱顺序。再者如果你在监听器里再调用断言会触发递归事件那是灾难。监听器里只做记录和转发绝不要触发新断言。5. 参数化测试从 INSTANTIATE 宏看实例注册流程5.1 TestWithParam 与 GetParam 的类型桥参数化测试源码的入口是include/gtest/gtest-param-test.h。TEST_P生成的测试类父类是TestWithParamT而这个类的全名其实是TestWithParam它继承自Test和WithParamInterfaceT。WithParamInterfaceT提供了GetParam()方法。它的内部实现不复杂参数值保存在一个ParamGeneratorT或者类似std::tuple的容器里框架在运行前会把参数注入到测试实例中。所以测试执行时GetParam()返回的是框架注入的那个值而不是从全局变量里取。理解这个类型桥很重要。源码角度来说TEST_P和TEST的区别在于TEST_P生成的类是一个模板工厂它不知道具体参数值参数值是后来通过INSTANTIATE_TEST_SUITE_P提供的。也就是说TEST_P只定义了“怎么测”INSTANTIATE才定义了“测哪些”。5.2 INSTANTIATE_TEST_SUITE_P 的注册链INSTANTIATE_TEST_SUITE_P的宏展开比TEST_P更复杂核心逻辑是创建一个匿名静态对象这个对象的构造函数里会迭代你传进来的参数序列并为每个参数值调用注册函数生成一个带编号的测试实例。大致流程是宏展开生成一个静态实例类型是实现参数化注册的内部类。该实例的构造函数调用参数生成器得到所有参数值。对每个参数值调用ParameterizedTestSuiteRegistry的注册函数。注册时会把TEST_P定义的那个测试模板实例化出具体版本并分配一个名字。比如你写了INSTANTIATE_TEST_SUITE_P(Nums, MyTest, Values(1, 2, 3))最终框架里会出现三个测试实例名字类似Nums/MyTest/0、Nums/MyTest/1、Nums/MyTest/2。那个序号其实就是参数序号。如果想给参数起可读的名字可以用Values(a, b)配合能打印的类型或者直接重载打印函数。参数名生成依赖operator或打印函数如果你的参数类型没有流输出实现就会退化成纯编号。5.3 参数名与“未实例化”报错实际开发中参数化测试最常见的编译错误是undefined reference to ... INSTANTIATE_TEST_SUITE_P ...或者运行时提示“没有注册任何测试”。这类问题直接看源码就能明白原因TEST_P只生成了一个模板化的注册入口真正把测试推进注册表的动作发生在INSTANTIATE_TEST_SUITE_P宏生成的静态对象构造中。你忘了写INSTANTIATE_TEST_SUITE_P那这个静态对象就不存在测试自然没注册。还有一类问题是参数类型不支持打印时报错。gtest 需要为参数生成名称如果类型没有可用的流输出操作符代码就无法通过编译。解决办法是给类型实现operator或者在TEST_P内部完全不依赖默认打印。顺手提一句老代码里经常见到INSTANTIATE_TEST_CASE_P这是旧版命名。新版源码虽然还保留兼容宏但内部已经完全迁到SUITE后缀。见到TestCase和TestSuite混用不用慌记住Suite是新的就行。6. 查源码时的几个实用技巧6.1 用预处理展开宏看真身读 gtest 源码最难啃的是宏。宏不像函数你点一下跳转就能看到完整实现它可能嵌套了七八层。我的做法是直接让编译器把宏展开看展开后的真实代码。在命令行对测试文件做预处理并把结果里带_Test的类名打印出来g -E test.cc | grep -A 30 MySuite_MyTest_Test这样你能直接看到TEST宏到底生成了哪些类、哪些变量、哪些注册代码。比在源码里层层跳转更直白。我第一次用这招时被展开出来的那一大坨代码震慑住了但也正是那一刻才真正理解了“宏就是文本替换”这件事有多暴力。如果你在 IDE 里开发也可以右键宏选择“展开宏”效果类似。这个技巧不仅适用于 gtest任何重度使用宏的库都适用。6.2 在注册点打断点看调用栈读源码容易有一个问题光看代码知道会注册但没感知它什么时候发生。最好的办法是直接在MakeAndRegisterTestInfo打一个断点然后启动任意测试程序。你会发现程序还没进main()断点就命中了几十次或上百次。每命中一次调用栈里都能看到一串静态初始化的过程。这会给你极大的冲击感原来测试用例在main()之前就已经全部排队等着了。顺着这个断点往调用栈上层看能看到具体是哪个源文件的哪个 TEST 宏在注册。配合栈回溯逐个展开你会对整个“静态注册”机制有非常直观的体感。这是光读源码很难获得的认知。6.3 源码更新后的坑最后说一个经验。gtest 的源码迭代并不慢内部类名、宏名都变过。如果你在阅读中遇到某段代码报错或者找不到定义先别怀疑自己先检查版本。我遇到过好几次这样的情况网上教程里写的是testing::internal::UnitTestImpl我手上这个版本改成了别的名字或者某内部函数从src/gtest.cc挪到了gtest-internal-inl.h。这类问题在源码阅读中非常常见反而是正常的。读源码时不要试图理解每一个宏。gtest 处理老旧编译器的兼容宏、平台差异宏非常杂这些跟测试框架的思想关系不大。把UnitTest、TestSuite、TestInfo、TestResult、监听器这条主干搞清楚就已经超过了绝大多数使用者。后续真要魔改或者移植再对照具体场景去查分支代码也不迟。另外提醒一句如果想把 gtest 源码移植到资源受限的环境要注意gtest-port.h里的平台判断和控制宏很多平台相关代码集中在这里。但这是另一个话题了我这边暂时不展开。本文还有配套的精品资源点击获取
返回列表