ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++单元测试实战:GoogleTest从入门到CI落地

C++单元测试实战:GoogleTest从入门到CI落地 在 C 项目里引入单元测试很多团队都会经历同一个阶段想写但不知道从哪个文件开始知道有 GoogleTest 这个框架结果配置 CMake 时一头雾水好不容易把第一个用例跑起来又发现用例之间相互拖累改了业务代码就红一片。如果你正处于这个阶段这篇教程应该能帮你把“GoogleTest”这条线完整走通——从概念、集成、写用例到工程化和 CI 落地一次性梳理清楚。本文以 C 17 和 CMake 为演示环境围绕 GoogleTest 最常用的TEST、TEST_F、断言、参数化测试展开。新手可以先看概念部分已经会写基础用例的读者可以直接跳到实战和工程最佳实践章节。文末还整理了高频报错的排查思路建议收藏备用。1. 背景与核心概念GoogleTest 到底解决什么问题1.1 单元测试为什么重要单元测试的核心思想很简单把程序拆到函数、类、模块这种最小粒度然后针对“一个输入对应一个预期输出”的行为写自动化验证。C 这种语言天然给单元测试增加了不少成本——内存管理、编译链接、跨平台差异任何一环都可能让测试难以下手。如果团队没有统一的框架测试代码很容易变成一堆main函数里的临时验证脚本时间一长既没有人敢改代码也没有人能说清楚哪些行为是被保护的。GoogleTest也叫 googletest就是为了解决这个问题而诞生的 C 单元测试框架。它由 Google 维护目前已经是 C 社区使用最广泛的测试框架之一。它不仅提供断言、测试套件、测试夹具这些基础能力还支持参数化测试、死亡测试、事件监听等进阶功能并且和 CMake、CI 工具的配合非常成熟。1.2 GoogleTest 与 C 测试生态的关系GoogleTest 通常和 Google Mock简称 gmock一起使用。gmock 是 GoogleTest 的扩展模块专门用来做模拟对象适合测试依赖外部服务、数据库、网络接口的代码。本文主要写 GoogleTest 本体但使用FetchContent集成时会把 gmock 一并拉下来未来需要 mock 时可以直接在同一套框架内扩展。容易混淆的一个概念是“测试框架”和“测试运行器”GoogleTest 负责用断言判断结果而测试程序本身负责执行用例并汇总报告。GoogleTest 通过定义入口函数来运行所有注册的用例——通常我们链接GTest::gtest_main它会自动生成main函数你不用自己写。1.3 什么时候值得上 GoogleTest如果你遇到下面这些场景GoogleTest 是性价比较高的选择项目逻辑复杂重构时害怕改坏旧行为核心算法、工具函数、协议解析需要保证输入输出稳定多人协作希望通过自动化测试守住接口契约老项目没有测试你想逐步给关键模块补上测试“安全网”。从工程角度讲GoogleTest 最优秀的一点是“侵入性低”它不需要你修改生产代码的结构只要在 CMake 里增加测试目标写一个测试文件就能把已有模块纳入测试体系。2. 环境准备使用 CMake 将 GoogleTest 集成进项目2.1 前置依赖本文示例环境如下版本可结合你的实际项目调整操作系统Ubuntu 22.04 / macOS / Windows 均可编译器GCC 9、Clang 10 或 MSVC 2019构建工具CMake 3.14 及以上C 标准C17如果你的项目还在用 C11大部分用法也兼容但后面示例中的结构化绑定和部分 CMake 写法需要做调整。2.2 目录结构规划建议将测试代码独立到tests目录不要和生产代码混在一起。本文示例项目结构如下calculator/ ├── CMakeLists.txt ├── src/ │ ├── calculator.h │ └── calculator.cpp └── tests/ └── test_calculator.cpp这种结构的好处是生产代码不需要关心测试代码的编译测试代码可以明确引用被测模块的公开头文件未来如果要拆分成多个库测试目标也能跟着独立调整。2.3 在 CMake 中获取 GoogleTest推荐使用 CMake 的FetchContent方式在配置项目时自动下载并构建 GoogleTest。这样团队成员不需要手动安装任何第三方库只要 clone 仓库后执行 CMake 即可。cmake_minimum_required(VERSION 3.14) project(CalculatorDemo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) include(FetchContent) FetchContent_Declare( googletest URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz ) FetchContent_MakeAvailable(googletest) add_library(calc src/calculator.cpp) target_include_directories(calc PUBLIC src) enable_testing() add_executable(test_calc tests/test_calculator.cpp) target_link_libraries(test_calc PRIVATE calc GTest::gtest_main) add_test(NAME unit_tests COMMAND test_calc)这里的GTest::gtest_main是 GoogleTest 提供的 CMake 导入目标。链接它之后测试程序会自带main函数你只需要专注写用例。如果项目已经拉取了 googletest 源码也可以直接使用add_subdirectory(googletest)效果类似只是需要你提前维护源码目录。2.4 编译和运行测试在项目根目录执行mkdir build cd build cmake .. cmake --build . ctest --output-on-failure用ctest的好处是它和 CMake 天然集成后面接 CI 时非常方便也可以直接运行生成的./test_calc查看更详细的控制台输出。3. 核心语法断言、TEST 与 TEST_F3.1 断言一族EXPECT_* 与 ASSERT_*GoogleTest 的断言分为两类EXPECT_*断言失败时输出错误信息但继续执行当前用例ASSERT_*断言失败时立即终止当前用例后续代码不再执行。如果某个断言失败后后面的语句依赖前面断言的结果或者已经处于不可恢复的状态就应该使用ASSERT_*如果希望一次运行尽量多地收集失败信息则使用EXPECT_*。常用断言示例EXPECT_EQ(calc.Add(1, 2), 3); // 相等 EXPECT_NE(calc.Add(1, 2), 0); // 不相等 EXPECT_TRUE(calc.IsPositive(3)); // 为真 EXPECT_FALSE(calc.IsPositive(-1)); // 为假浮点数比较要特别小心。直接使用EXPECT_EQ比较double很容易受到精度影响因此 GoogleTest 专门提供了EXPECT_DOUBLE_EQ和EXPECT_NEAREXPECT_DOUBLE_EQ(calc.Divide(1.0, 3.0), 1.0 / 3.0); EXPECT_NEAR(calc.Divide(1.0, 3.0), 0.3333333333, 1e-9);EXPECT_DOUBLE_EQ内部使用 ULP浮点数最小精度单位比较对大多数场景足够当你要指定明确误差范围时用EXPECT_NEAR更直观。3.2 TEST最基础的用例TEST宏是 GoogleTest 最基础的定义方式第一个参数是测试套件名第二个参数是用例名。一个测试套件内的用例可以一起过滤、一起统计。#include gtest/gtest.h int Add(int a, int b) { return a b; } TEST(AddTest, PositiveNumber) { EXPECT_EQ(Add(1, 2), 3); } TEST(AddTest, NegativeNumber) { EXPECT_EQ(Add(-1, -1), -2); }编译并运行后GoogleTest 会报告[] Running 2 tests from 1 test suite. [----------] 2 tests from AddTest [----------] Global test environment tear-down [ PASSED ] 2 tests.这里的关键点是TEST(AddTest, PositiveNumber)其实是在定义两个不同的函数GoogleTest 通过宏在编译期把它们注册到测试框架中。你不必关心注册细节但要知道同一个测试套件下可以有多个独立用例。3.3 TEST_F测试夹具让用例共享状态TEST适合无状态或函数式测试。但很多 C 类在测试时需要先创建对象、准备环境、填充数据。如果每个用例都重复这些初始化代码会非常冗余。TEST_F配合测试夹具类Fixture可以解决这个问题。夹具类需要继承::testing::Test在SetUp()中完成初始化在TearDown()中做清理。#include gtest/gtest.h #include memory class CalculatorTest : public ::testing::Test { protected: void SetUp() override { calc std::make_uniqueCalculator(); } void TearDown() override { calc.reset(); } std::unique_ptrCalculator calc; }; TEST_F(CalculatorTest, AddTwoNumbers) { EXPECT_EQ(calc-Add(1, 2), 3); }注意TEST_F的第一个参数必须是夹具类的类名而不是测试套件名。每个用例运行时都会重新创建一个夹具实例因此不同用例之间不会共享成员变量状态这保证了用例的独立性。3.4 测试命名不能包含下划线这是新手最容易踩的坑之一。GoogleTest 明确规定TEST和TEST_F的测试套件名、用例名都不能包含下划线_。原因是宏展开后会生成TestSuiteName_TestName_Test这样的类名包含下划线时会导致类名冲突或含义模糊。// 不推荐可能产生命名冲突 TEST(Calculator_Test, add_test) { // ... }命名规范应该是类似CalculatorTest或AddTest这种驼峰风格尽量不要在测试名字里使用下划线。如果是从 Python 或其他语言转过来的开发者这一点尤其需要留意。4. 完整实战为计算器模块编写可维护的单测4.1 需求说明与项目文件现在进入实战部分。我们模拟一个计算器模块它对外提供加减乘除四个方法。除法需要处理除数为 0 的异常情况。先创建被测模块的头文件和实现文件。4.2 被测模块代码// 文件路径src/calculator.h #pragma once namespace calc { class Calculator { public: double Add(double a, double b) const; double Subtract(double a, double b) const; double Multiply(double a, double b) const; double Divide(double a, double b) const; }; } // namespace calc// 文件路径src/calculator.cpp #include calculator.h #include stdexcept namespace calc { double Calculator::Add(double a, double b) const { return a b; } double Calculator::Subtract(double a, double b) const { return a - b; } double Calculator::Multiply(double a, double b) const { return a * b; } double Calculator::Divide(double a, double b) const { if (b 0.0) { throw std::invalid_argument(divisor must not be zero); } return a / b; } } // namespace calc被测模块没有依赖任何框架只是普通的类实现。这符合单元测试的核心原则测试不应该侵入生产代码设计。4.3 用 TEST 写第一轮用例先创建一个测试文件对计算器的基础行为做验证。这里选择用TEST直接写适合验证简单、无状态的函数。// 文件路径tests/test_calculator.cpp #include gtest/gtest.h #include stdexcept #include calculator.h TEST(CalculatorTest, AddPositiveNumber) { calc::Calculator calc; EXPECT_DOUBLE_EQ(calc.Add(3.0, 4.0), 7.0); } TEST(CalculatorTest, AddNegativeNumber) { calc::Calculator calc; EXPECT_DOUBLE_EQ(calc.Add(-3.0, -4.0), -7.0); } TEST(CalculatorTest, SubtractTwoNumber) { calc::Calculator calc; EXPECT_DOUBLE_EQ(calc.Subtract(10.0, 4.0), 6.0); } TEST(CalculatorTest, MultiplyTwoNumber) { calc::Calculator calc; EXPECT_DOUBLE_EQ(calc.Multiply(3.0, 4.0), 12.0); } TEST(CalculatorTest, DivideByZeroThrows) { calc::Calculator calc; EXPECT_THROW(calc.Divide(1.0, 0.0), std::invalid_argument); }这段代码里有几个值得注意的地方每个用例都创建了一个新的Calculator对象用例之间完全没有共享状态EXPECT_THROW用于验证异常抛出这是 C 测试里非常实用的断言浮点比较使用EXPECT_DOUBLE_EQ避免精度误差造成的不稳定。4.4 用 TEST_F 改造共享状态如果接下来需要测试同一个对象的一组行为或者测试用例中需要多次复用同一个初始化逻辑就可以用夹具类简化代码。class CalculatorFixtureTest : public ::testing::Test { protected: void SetUp() override { calc std::make_uniquecalc::Calculator(); } std::unique_ptrcalc::Calculator calc; }; TEST_F(CalculatorFixtureTest, AddAndSubtract) { double sum calc-Add(5.0, 3.0); double diff calc-Subtract(sum, 3.0); EXPECT_DOUBLE_EQ(diff, 5.0); } TEST_F(CalculatorFixtureTest, MultiplyAfterAdd) { double sum calc-Add(2.0, 3.0); EXPECT_DOUBLE_EQ(calc-Multiply(sum, 2.0), 10.0); }在这个例子中SetUp()中完成了calc的创建每个用例都可以直接使用calc指针。GoogleTest 会对每个用例重新调用一次SetUp()所以两个用例之间的calc是完全独立的。4.5 参数化测试去掉重复代码当多个用例只有参数不同、行为完全一致时可以用TestWithParamT实现参数化测试。这样既能减少代码重复又能让测试数据集中管理。#include tuple class CalculatorParamTest : public ::testing::TestWithParamstd::tupledouble, double, double { protected: calc::Calculator calc; }; TEST_P(CalculatorParamTest, Add) { auto [a, b, expected] GetParam(); EXPECT_NEAR(calc.Add(a, b), expected, 1e-9); } INSTANTIATE_TEST_SUITE_P( AddCases, CalculatorParamTest, ::testing::Values( std::make_tuple(1.0, 2.0, 3.0), std::make_tuple(-1.0, 1.0, 0.0), std::make_tuple(0.1, 0.2, 0.3), std::make_tuple(100.0, -50.0, 50.0) ) );对应关系如下TestWithParamstd::tuple...表示每个参数是一个三元组GetParam()获取当前参数INSTANTIATE_TEST_SUITE_P把参数列表绑定到测试套件上参数化后每组参数都会作为一个独立用例运行和统计。这是工程中最实用的能力之一。当测试数据越来越多时只需要往Values(...)里加一组参数而不需要复制粘贴整个测试函数。4.6 构建运行与预期结果完整测试文件已经就绪。回到构建目录执行cmake --build . ctest --output-on-failure或者直接运行测试程序./test_calc你会看到类似下面的摘要表示所有用例通过[] Running 10 tests from 4 test suites. [----------] Global test environment tear-down [] 10 tests from 4 test suites ran. [ PASSED ] 10 tests.实际用例数量取决于你加入了多少个参数化参数。参数化用例在控制台中会以AddCases/CalculatorParamTest.Add/0这样的编号展示方便定位是哪一组参数导致的失败。5. 常见问题与排查思路5.1 高频问题汇总GoogleTest 的使用过程中很多报错其实是共性的。下面这张表可以作为排查清单问题现象常见原因解决思路编译时报gtest/gtest.h: No such file or directory没有正确获取或构建 GoogleTest确认 CMake 中已调用FetchContent_MakeAvailable或add_subdirectory并检查依赖目标链接是否正确链接时报undefined reference to testing::...测试目标没有链接GTest::gtest_main在target_link_libraries中补充GTest::gtest_main注意链接顺序测试套件名包含下划线时编译报错GoogleTest 不允许套件名和用例名包含_改用驼峰命名例如CalculatorTest用EXPECT_EQ比较浮点数偶尔失败浮点精度导致的不稳定改用EXPECT_DOUBLE_EQ或EXPECT_NEARTEST_F报class ... : public ::testing::Test相关错误测试类没有继承::testing::Test或第一个参数不是夹具类名检查夹具类定义确保使用的是类名而不是测试套件名调用cmake ..时下载 googletest 超时网络问题或源地址不可达可提前下载源码目录改用add_subdirectory方式或者切换版本 tag 重试5.2 链接错误的详细说明链接阶段最常见的错误是undefined reference to testing::internal::...通常原因是test_calc目标只链接了被测模块而忘了链接GTest::gtest_main。GoogleTest 需要gtest核心断言库和gtest_main入口函数两个部分。如果只写GTest::gtest测试程序缺少main就会在链接阶段报错。正确写法target_link_libraries(test_calc PRIVATE calc GTest::gtest_main)5.3 测试用例“一闪而过”怎么办有时在 IDE 中直接点击运行测试程序窗口一闪而过看不清输出。可以先尝试命令行执行cd build ./test_calc --gtest_coloryes--gtest_coloryes可以让失败用例用红色高亮显示更容易定位问题。如果用例多也可以使用--gtest_filterCalculatorTest.*只运行某个套件的用例。6. 工程最佳实践与 CI 集成6.1 命名规范套件名与用例名GoogleTest 和 Google C 命名风格是紧密配合的。测试名称建议能表达“被测行为”测试套件名使用被测类名或模块名例如CalculatorTest、ParserTest用例名使用动词短语描述行为例如AddPositiveNumber、DivideByZeroThrows禁止在套件名和用例名中使用下划线保持一致性和可读性。测试数据变量、夹具类成员也尽量使用calc、parser这类简洁名字避免每个用例内部出现无意义的长命名。6.2 保持用例独立性一条重要的原则是每个用例都应该能独立运行、独立失败。GoogleTest 并不保证用例的执行顺序所以不要假设某个用例会先运行。具体建议尽量在SetUp()中创建被测对象不要在用例之间共享全局状态测试中如果修改了外部文件、数据库或全局变量必须在TearDown()中恢复一个用例只验证一组行为不要一个用例里塞十几个断言否则失败时很难定位真正的问题。6.3 覆盖率统计与测试报告单元测试不是写得越多越好而是要看核心逻辑有没有被覆盖到。使用 GCC 或 Clang 时可以开启覆盖率选项cmake -DCMAKE_CXX_FLAGS--coverage -g .. cmake --build . ./test_calc生成.gcda文件后用lcov或gcovr生成 HTML 报告。覆盖率是一个参考指标不用追求 100%但核心模块、算法分支、异常路径建议优先覆盖。6.4 在 CI 中运行测试在持续集成流水线中GoogleTest 的接入成本很低。以 GitHub Actions 为例可以这样配置name: unit-test on: push: pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Configure run: cmake -S . -B build - name: Build run: cmake --build build - name: Run tests run: ctest --test-dir build --output-on-failure如果使用 GitLab CI也可以定义类似的script步骤。关键在于ctest返回非 0 退出码时CI 会判定任务失败从而拦截已破坏的代码合并。6.5 从 0 到 1 的落地顺序给老项目补测试时不建议一开始就追求全覆盖。可以按这样的顺序推进先给工具函数、纯算法类模块编写基础用例再为 IO 边界、异常路径补充测试对依赖外部的模块引入 gmock 模拟依赖把测试纳入 CI形成提交即验证的闭环。7. 总结与下一步学习路线通过这篇教程你应该已经掌握 GoogleTest 的完整使用链路理解单元测试和断言的基本概念能够用 CMake 集成 GoogleTest会使用TEST和TEST_F编写测试用例并能通过参数化测试减少重复代码。文中的计算器实例虽然简单但它的结构可以直接推广到真实的业务项目里核心算法、异常处理、参数组合这些都是测试最容易切入的点。接下来可以往三个方向深入 一是阅读 GoogleTest 官方文档掌握死亡测试Death Test、事件监听器Event Listener等进阶能力二是学习 gmock解决外部依赖难以构造的问题三是研究覆盖率工具和测试报告平台把单测体系做得更完整。当你在遗留系统里重构时不妨先用 GoogleTest 把关键行为固化成用例再动手改实现。看着一片绿色用例通过那种底气会比直觉判断可靠得多。如果本文对你有帮助可以收藏备用也欢迎在实际项目中验证这些配置和写法。
返回列表