ARTICLE DETAIL

资讯详情

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

C++单元测试实战:用gtest构建可靠代码防线

C++单元测试实战:用gtest构建可靠代码防线 1. 为什么你的C代码库需要一套真正的单元测试1.1 从改一处崩三处说起做C开发的人多少都有过这种经历一个功能在本地跑得好好的结果改了一个边界条件之后某些看起来完全无关的模块突然开始炸。更绝望的是问题不一定是当场出来的可能是一周之后测试同学拿着一个复现路径找上门你打开调试器看了半天代码逻辑怎么都对最后才发现是最早改动的那一行把一个共享状态的初始顺序破坏了。这种时候单元测试的意义就体现出来了。我之前也经历过很长一段时间的验证靠打印、回归靠记忆的阶段函数写得越来越复杂调用链越来越深每改一行代码都要把相关功能手动点一遍工作量大不说漏掉的那一次往往就是线上事故。后来把gtest引入项目之后情况改善得非常明显。gtest全称GoogleTest是Google开源的一个C单元测试框架围绕它还有一个配套的mock框架gMock。它在C社区里的地位差不多等同于JUnit在Java里、pytest在Python里的位置。你只需要写测试用例声明什么样的输入应该产生什么样的输出剩下的事情——运行、对比、统计、失败报告——框架全部帮你搞定。这篇文章适合三类读者一是从来没写过单元测试想给C项目补课的同学二是在用某个轻量框架但觉得能力不太够想换gtest的三是已经用过gtest但只停留在TEST宏层面想看看更完整的工程实践该怎么搭的。内容会偏实战每一章我都会给出能直接跑的代码和具体的坑点。1.2 手动测试和打印大法的边界先聊聊很多人习以为常的验证方式写个main函数初始化数据调用待测函数然后printf或者cout打印结果再用肉眼比对一遍。这种方式在小规模验证时没问题但随着代码量上升它会遇到几个让人头疼的边界第一肉眼比对不可靠。人看输出的时候注意力会受到很多因素影响。一个超长字符串里少了个空格一行浮点数差0.0001鼠标一滚就过去了。而单元测试框架的断言语义是精确的两个值不等就是失败不需要人二次判断。第二没有回归能力。今天你改了函数A的边界条件你只会手动去点A。但从代码依赖的角度B、C、D都可能间接用到A的行为。手动测试做不到每次改动都把全项目的功能点一遍但单元测试可以——一条命令几十上百个用例全部重跑。第三被动复现而不是主动发现。手动测试是出了bug才去复现单元测试是在代码阶段就模拟边界条件主动验证。很多线上才暴露的问题如果在写代码的时候就把它翻译成测试用例基本当场就能发现。gtest帮我们解决的正是这三点精确断言、低成本回归、主动验证。本质上它做的事情不复杂就是找到所有测试用例、按某种结构组织、运行、对比、输出报告但做完善了用起来就非常顺手。1.3 对比之后我为什么选择gtestC的测试框架其实不少Catch2、Doctest、QTest、Boost.Test都是不错的选择。我最初也纠结过Catch2——因为它单头文件、编译快、现代感强。但gtest有几个让我最终倒向它的理由断言非常全。数值、布尔、字符串、浮点、异常甚至子过程是否崩溃都能测。日常需要的基本都覆盖了很少需要自己造轮子。测试生命周期管理成熟。SetUp/TearDown的夹具机制设计得很严谨单个用例的隔离性做得很好。参数化测试强大。值参数化、类型参数化一份测试函数喂几十种输入处理那种同一条逻辑要适配多种情况的需求非常舒服。生态完整。配合gMock能做接口Mock配合lcov/OpenCppCoverage能做覆盖率配合CTest能直接挂进CMake构建流程。资料多、坑少。社区庞大遇到问题基本搜得到答案。当然Catch2也很好但如果你想让整个团队快速统一到一套熟练的测试体系gtest依然是我见过综合成本最低的选择。2. 先把gtest跑起来环境安装与工程骨架2.1 Linux下最常见的两种接入方式在Linux上使用gtest主流方式有两种系统包管理和源码引入。系统包管理最简单Debian/Ubuntu系跑一条命令sudo apt install libgtest-dev不过装完之后要注意很多发行版不会自动帮你编译库文件你需要自己去源码目录编一下或者干脆用cmake构建一次。还有更省心的方案通过CMake的FetchContent直接把gtest源码拉到项目里编译不污染系统环境版本控制也更精确。我个人比较推荐这个方式因为每个人的项目对gtest版本的要求可能不一样FetchContent能锁定到你想要的tag。在CMakeLists.txt里写这段include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG v1.14.0 ) set(gtest_force_shared_crt ON CACHE BOOL FORCE) FetchContent_MakeAvailable(googletest)然后你的测试目标只要链接gtest_main或者gtest就可以。注意gtest_main已经自带了一个main入口如果你不需要自己写main函数链接它最省事但有些项目需要自定义main那就链接gtest然后自己提供一个main函数在里面调用::testing::InitGoogleTest(argc, argv); return RUN_ALL_TESTS();。2.2 Windows上那些折腾人的编译报错怎么破Windows上玩gtest踩坑概率明显高很多。很多同学第一次跑就遇到这个经典报错error: Microsoft Visual C 14.0 or greater is required. Get it with Microsoft C Build Tools看到这个先别懵这句话的意思是系统里缺少MSVC编译工具链。解决方案是去微软官网下载Visual Studio Build Tools安装时勾选使用C的桌面开发工作负载这一项里包含了编译器、Windows SDK和其他必要库。只装了Visual Studio Code是不够的因为VS Code本身只是一个编辑器它不会自动带你VS编译工具。装完之后还要注意CMake在生成项目时要能检测到编译器。我建议直接在x64 Native Tools Command Prompt for VS这种终端里面跑cmake这样环境变量是齐全的。用VS Code的话选择一个正确的编译器套件不要混用MinGW和MSVC编译出来的库否则链接时会报一堆无法解析的外部符号。另外一个高频报错是Cannot specify link libraries for target gtest_main which is not built by this project.大概率是因为FetchContent没生效或者你把add_executable写在了FetchContent_MakeAvailable之前。顺序一定不能反先让gtest工程被加载再声明你自己的可执行文件。还有一点如果你的CMake版本处于3.10到3.14之间FetchContent是不存在的需要升级CMake。我建议直接装3.22以上版本省掉很多兼容性烦恼。2.3 一个最小工程示例来看一个最小的完整工程三份文件就可以跑起来目录结构my_project/ ├── CMakeLists.txt └── tests/ └── test_demo.cppCMakeLists.txtcmake_minimum_required(VERSION 3.16) project(MyDemoProject VERSION 1.0.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG v1.14.0 ) FetchContent_MakeAvailable(googletest) enable_testing() add_executable(test_demo tests/test_demo.cpp) target_link_libraries(test_demo gtest_main) include(GoogleTest) gtest_discover_tests(test_demo)test_demo.cpp#include gtest/gtest.h int Add(int a, int b) { return a b; } TEST(AddTest, PositiveNumbers) { EXPECT_EQ(Add(1, 2), 3); EXPECT_EQ(Add(10, 20), 30); } TEST(AddTest, NegativeNumbers) { EXPECT_EQ(Add(-1, -1), -2); }编译运行mkdir build cd build cmake .. cmake --build . ctest --output-on-failure如果没有意外你会看到所有测试通过。到这里环境就通了之后的探索都在这套骨架上做。3. 断言体系gtest给你的一把卡尺3.1 EXPECT_与ASSERT_的分工gtest的断言宏是测试用例的灵魂几乎每个测试函数都由若干断言组成。刚接触时最容易忽略的一个设计就是绝大部分断言都有两个版本一个是EXPECT_前缀一个是ASSERT_前缀。两个版本的区别在于失败行为的严重程度EXPECT_EQ比较失败后记录失败信息但继续执行当前测试函数后面的代码。适合做一系列独立校验即使前面一个条件不满足后面的校验仍有诊断价值。ASSERT_EQ比较失败后立即终止当前测试函数后面的代码不再执行。适合做前置门槛校验比如一个测试需要先将对象初始化到某个状态初始化失败后面就不用测了。实际写代码时我习惯遵循一个原则有依赖关系的前置条件用ASSERT_没有依赖关系的独立结论用EXPECT_。比如先ASSERT_NE(ptr, nullptr)再EXPECT_NE(ptr-value(), 0)因为对空指针解引用会导致崩溃这种崩溃信息远没有一条干净利落的断言失败报告有价值。3.2 数值、布尔、字符串与异常断言用表格把常用断言整理出来类别断言宏含义通用比较EXPECT_EQ(a, b)a b数值类最常用通用比较EXPECT_NE(a, b)a ! b通用比较EXPECT_LT/LE/GT/GE(a, b)a b / a b / a b / a b布尔EXPECT_TRUE(expr)表达式为true布尔EXPECT_FALSE(expr)表达式为false指针EXPECT_EQ(nullptr, p)p为空指针指针EXPECT_NE(nullptr, p)p非空指针字符串EXPECT_STREQ(a, b)C字符串逐字节相等字符串EXPECT_STRNE(a, b)C字符串不相等字符串EXPECT_THAT(s, HasSubstr(...))子串匹配配合Hamcrest浮点EXPECT_FLOAT_EQ(a, b)float按ULP容差比较浮点EXPECT_DOUBLE_EQ(a, b)double按ULP容差比较浮点EXPECT_NEAR(a, b, abs_error)绝对误差范围比较异常EXPECT_THROW(expr, ExType)抛出的异常类型是ExType异常EXPECT_NO_THROW(expr)不抛任何异常异常EXPECT_ANY_THROW(expr)抛出任意异常浮点比较这列值得多说两句。C里直接EXPECT_EQ(0.1 0.2, 0.3)大概率是失败的因为二进制浮点数的表示有误差。EXPECT_DOUBLE_EQ比较的是浮点二进制表达上是否足够接近它允许极小的误差EXPECT_NEAR则更直观你指定一个绝对允许误差比如EXPECT_NEAR(result, 3.14, 1e-6)表示只要结果落在3.14加减1e-6范围内就算通过。在做数值计算类库的测试时EXPECT_NEAR几乎是我用得最多的断言。字符串比较还有一个容易踩的坑EXPECT_EQ(str1.c_str(), str2.c_str())比较的是两个const char*的指针地址而不是字符串内容指针地址不同肯定不相等所以字符串比较一定要用EXPECT_STREQ。这个坑我在刚入门时踩了不止一次当时死活想不通为什么内容明明相同却报失败。3.3 让测试输出有意义的失败信息默认情况下一条失败的断言会打印两个操作数的值。但有些时候两个操作数的可读性很差。比如你在一堆配置参数里校验一个状态码失败信息只显示Expected: 1, Actual: 0看到的人很可能要翻回测试代码才明白到底哪里不对。这时候可以用流式输出追加自定义信息EXPECT_EQ(config.mode, Mode::AUTO) Configuration load failed! input file: configFilePath , current mode: static_castint(config.mode);失败时该信息会原样打印出来。很多老手也会用这个技巧把当前测试的输入参数打出来排查参数化测试失败时就很有用。4. 用TEST_F把重复初始化扫进垃圾箱4.1 测试夹具的运行机制当你有多条测试用例需要共享一套构造数据、初始资源或者清扫逻辑时就应该把公用的东西提取到测试夹具里。gtest里的测试夹具是一个继承自::testing::Test的类核心机制是重写两个虚函数SetUp()每个测试用例执行之前自动调用负责初始化环境、准备数据。TearDown()每个测试用例执行之后自动调用负责释放资源、清扫状态。对应的测试宏从TEST换成TEST_F其中F是Fixture的缩写。第一个参数从测试套件名变成夹具类名第二个参数依然是用例名。注意两个关键点第一TEST_F第一个参数必须是夹具类名否则编译直接报错第二不要手动调用SetUp和TearDowngtest在运行每个用例时会严格按类构造 - SetUp - 测试体 - TearDown - 类析构的顺序执行。这个机制的好处是什么它保证每个测试用例面对的都是一个全新的对象。对于有内部状态、依赖文件、依赖网络句柄的模块夹具能在每次用例执行前把状态清干净避免多个用例之间因为共享全局状态而产生难以定位的不确定性。4.2 实战一个UserManager类的测试假设你有一个用户管理类它依赖一个数据库连接和一个配置对象测试它需要先连接数据库、插入一些基础数据、然后测试增删改查逻辑。最朴素的写法是每个测试用例里都复制粘贴一遍初始化代码。用夹具一步到位class UserManagerTest : public ::testing::Test { protected: void SetUp() override { db_ Database::Connect(test_config.ini); manager_ std::make_uniqueUserManager(db_); ASSERT_NE(db_, nullptr); // 连不上库就终止继续测没有意义 manager_-AddUser({alice, aliceexample.com}); manager_-AddUser({bob, bobexample.com}); } void TearDown() override { manager_-ClearAllUsers(); db_-Disconnect(); } std::shared_ptrDatabase db_; std::unique_ptrUserManager manager_; }; TEST_F(UserManagerTest, AddUserShouldIncreaseCount) { int before manager_-UserCount(); EXPECT_TRUE(manager_-AddUser({carol, carolexample.com})); EXPECT_EQ(manager_-UserCount(), before 1); } TEST_F(UserManagerTest, DuplicateUserShouldFail) { EXPECT_FALSE(manager_-AddUser({alice, aliceexample.com})); }这里的ASSERT_NE(db_, nullptr)是一个典型的前置门槛校验。如果数据库没连上后续所有操作都会在空指针上崩溃崩溃信息远没有这条断言报告清晰。4.3 生命周期陷阱为什么每个用例都像新开局gtest的夹具机制有一个值得强调的语义同一个测试套件内的不同用例彼此是完全隔离的。也就是说每个用例执行时都会创建一个全新的夹具对象执行SetUp执行测试体然后销毁。你不要指望在一个用例里修改的成员变量能带到下一个用例去。这个设计初看可能觉得浪费好像每个用例都重建一遍代价很高。但正是这种隔离性让单元测试变得可预期。最典型的一个教训是如果某个测试用例依赖上一个用例留下的人或者上一次SetUp设置的状态那么一旦测试顺序调整或者某些用例被--gtest_filter筛掉部分测试就会变得不可控。而gtest故意把每条用例当作独立的个体来对待就是逼迫你写出来的测试不含隐式耦合。如果你的初始化确实非常耗时比如要启动一个重量级组件可以用TestSuite级别的共享夹具——继承::testing::Environment或在Test类里声明static void SetUpTestSuite()/static void TearDownTestSuite()。但我的建议是性能问题最后再优化先用清晰的用例隔离把正确性保住。5. 参数化测试一份数据跑全矩阵5.1 值参数化与INSTANTIATE_TEST_SUITE_P单元测试写过一段时间之后你会发现大量测试用例是同一个函数换不同输入。如果每个输入都写一个TEST代码会非常冗余而且以后加一个新输入时要到处复制粘贴。gtest为此提供了值参数化测试。核心步骤有三步声明一个夹具类继承::testing::TestWithParamTT就是输入参数的类型。用TEST_P宏写测试用例在用例里通过GetParam()取出当前参数值。用INSTANTIATE_TEST_SUITE_P实例化参数列表。看一个二叉搜索树删除操作的例子class BstDeleteTest : public ::testing::TestWithParamint { protected: BST bst_; void SetUp() override { for (int v : {5, 3, 8, 1, 4, 7, 9}) { bst_.Insert(v); } } }; TEST_P(BstDeleteTest, DeleteReturnsExpectedRemainingNodes) { bst_.Delete(GetParam()); EXPECT_FALSE(bst_.Contains(GetParam())); } INSTANTIATE_TEST_SUITE_P( BstDeleteValueSeries, BstDeleteTest, ::testing::Values(1, 3, 5, 7, 9) );运行时会生成5个用例每个都走一次SetUp分别为参数1、3、5、7、9。gtest生成的用例名会自动带上参数索引比如BstDeleteValueSeries/BstDeleteTest.DeleteReturnsExpectedRemainingNodes/0失败时你能直接看到是哪组参数挂了。5.2 组合参数测试有时候你需要的不是一维参数而是多组参数排列组合。比如排序算法要测不同数组长度 × 数据是否有序 × 数据是否含重复元素手动组合会爆炸。gtest提供::testing::Combine配合std::tuple搞定class SortTest : public ::testing::TestWithParamstd::tupleint, bool, bool { }; TEST_P(SortTest, ProducesSortedResult) { auto [size, alreadySorted, hasDuplicates] GetParam(); auto data GenerateArray(size, alreadySorted, hasDuplicates); Sort(data); EXPECT_TRUE(IsSorted(data)); } INSTANTIATE_TEST_SUITE_P( VariousInput, SortTest, ::testing::Combine( ::testing::Values(0, 1, 10, 1000), ::testing::Bool(), ::testing::Bool() ) );4种数组长度 × 2种顺序 × 2种重复情况一共16个用例但代码只有一份。当未来要新增一种数据特征只需要在Values或Bool里加一项测试矩阵自动扩大。我设计参数化场景时的经验是先列出边界值、典型值、异常值然后尽量把有代表性的组合都放进参数列表。比拍脑袋每个函数写七八个手搓用例参数化的维护成本低得多。5.3 过滤与禁用技巧在开发过程中你不想每次都跑全量测试。gtest内置了很灵活的过滤机制./test_demo --gtest_filterBstDeleteTest.* ./test_demo --gtest_filter*Delete* ./test_demo --gtest_filter-SortTest.*第一个运行指定套件第二个模糊匹配第三个排除指定套件。*通配符和-排除在前缀配合使用时非常高效。调试单个用例时我会直接--gtest_filterFixtureName.CaseName把无关测试全部跳过秒级反馈。如果某一组参数导致测试暂时无法通过又不想删掉用例可以在INSTANTIATE_TEST_SUITE_P的测试前缀中加DISABLED_比如INSTANTIATE_TEST_SUITE_P(DISABLED_BstDeleteValueSeries, ...)。或者给单独的TEST_P用例名加DISABLED_前缀。禁用时gtest会打印提示不会被静默忽略。这个做法适合暂时标注已知问题避免掩盖其他用例的失败信号。6. 当gtest进入真实项目CI、覆盖率与踩坑清单6.1 用CTest把测试挂进构建流程单独的测试可执行文件还不够理想的团队实践是工程师每次提交代码CI机器自动拉代码、构建、跑测试有任何用例挂掉立刻通知当事人。CMake生态里CTest就是干这件事的。前文的enable_testing()和gtest_discover_tests(test_demo)两行已经把测试用例注册进了CTest。这意味着你在构建目录执行ctest它会自动发现test_demo里所有测试用例并逐一运行。与直接运行可执行文件相比CTest带来几个额外优势统一入口。所有项目的测试都通过ctest运行CI配置不用关心每个测试二进制的具体路径。自动化格式。输出结果更结构化方便脚本解析状态码。资源管控。单个用例超时可以被杀掉阻塞用例不会拖死整个CI进程。动态注册新用例。只要代码里加了新的TEST/TEST_P重新构建后ctest自动识别不需要维护用例清单。在GitLab CI或GitHub Actions里最基础的流水线就是配置 - 构建 - ctest三步。把这几行挂进去项目质量就有了底线保障。6.2 覆盖率统计量化你的测了多少写完一堆测试用例之后心里难免会问一个问题我到底测了多少代码覆盖率工具就是回答这个问题的。Linux GCC环境流的方案是lcov。先给编译选项加两个flag重新编译测试目标cmake -DCMAKE_CXX_FLAGS--coverage -O0 -g .. cmake --build . ./test_demo lcov --capture --directory . --output-file coverage.info --ignore-errors mismatch genhtml coverage.info --output-directory html_report然后打开html_report/index.html能看到每个文件、每个函数的命中率。行覆盖率、分支覆盖率一目了然。Windows MSVC下Visual Studio的企业版自带覆盖率分析工具或者用OpenCppCoverageOpenCppCoverage.exe --sources my_project -- test_demo.exe它会把运行期间触达的C源文件标记出来。我的建议是覆盖率数据用来发现完全没有被测到的模块很有价值但不用追求100%。实际项目里UI层、第三方SDK胶水层、临时兼容代码覆盖率低一点完全可以接受。把有限的时间投入到核心算法、复杂状态机、跟钱和安全相关的逻辑上收益比最高。而且覆盖率必须和测试断言有效性一起看否则很容易出现覆盖了但只测了个函数能跑没测边界条件的假象。6.3 真金白银换来的gtest实战提醒最后分享几条在实际项目里反复踩过的坑基本每条都是血泪教训。第一不要在测试用例里依赖运行顺序。前面讲过夹具隔离但还有人会在测试之外建立全局变量、静态变量然后用例之间隐式共享状态。gtest按文件顺序、声明顺序执行用例今天顺序能过不代表明天加了新用例还能过。要做共享数据请明确走TestSuite级初始化的合法通道而不是靠恰好先跑了哪个测试。第二测试里用随机数据要注意可复现性。很多开发者喜欢用随机数生成大量输入来测模糊逻辑这是好事但如果随机种子不固定用例某次挂了复现却要碰运气。建议实测时明确设置种子或者在断言失败信息里打印种子保证可复现。同样道理测试里不要依赖系统时间、网络延迟、线程调度顺序。单元测试追求确定性不确定的来源尽量用Mock或注入参数替代。第三浮点断言不要裸用EXPECT_EQ。对数值计算逻辑一个有经验的C工程师会默认用EXPECT_NEAR并且把允许误差设置得贴合业务场景。误差给得太小高波动态的计算容易误报误差给得太大等于没测。一般从业务允许误差的1/10开始尝试观察稳定后再调整。第四注意断言宏里表达式的副作用。EXPECT_EQ(counter, 1)这种写法不仅有风险还容易误导直觉。如果counter在失败时被额外求值行为会和你预想的不一致。断言的参数应当是无副作用的表达式需要更新状态就把状态更新放到测试代码里而不是塞进断言里。第五TearDown里写清理逻辑没问题但如果在清理过程中抛异常也是悲剧。比如断开数据库连接抛了一个连接错误会覆盖掉原本用例断言的失败信息导致真正的问题被隐藏。稳妥做法是TearDown里的清理逻辑自己做好异常吞掉或者统一catch把主导权交还给测试体。第六当你引入gtest到旧项目时别幻想一次性把所有模块都补上测试。我在改造一个存量模块时最初的策略是先给新代码和被频繁修改的代码补测试把高频出问题的逻辑先用测试网兜住。等这些稳定了再逐步覆盖冷门分支。相比一步到位的激进方案这个渐进过程给团队带来的挫败感小得多持续性也更好。最后再分享一个小技巧如果团队协作用的是VS Code把ctest的快捷任务配置进去每次改完代码顺手跑一下成本低到可以养成习惯。等测试数量上来了你会发现改代码不怕了的那种安全感是会上瘾的。gtest本身不复杂真正复杂的养成用测试保护代码的习惯但一旦开始就回不去了。
返回列表