拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Ubuntu下GTest编译与CMake集成:C++单元测试实战指南

先说一个我经常被问到的问题Ubuntu下想用GTest跑单元测试为什么偏要自己用CMake编译一遍直接apt install libgtest-dev然后include、链接不就行了如果你也这么想那这篇文章值得看完。我在多个Ubuntu版本上做过GTest的编译和集成踩过不少坑也帮别人排查过很多次类似的问题。自己编译GTest这件事看起来多此一举实际上一旦你遇到版本太旧、CMake找不到包、ABI不兼容、链接报出一堆undefined reference这类问题就会明白直接装系统包的方案有多脆弱。这篇内容适合刚接触Linux下C开发、准备给项目引入单元测试的同学也适合已经在用GTest但想彻底搞懂“编译、链接、集成”整条链路的人。我会从“为什么要自己编译”讲起一直讲到项目里实际跑通测试再附上实操中高频出现的错误和排查思路。1. 为什么偏要自己编译GTestapt装好的库到底差在哪先回答开头那个问题。apt install libgtest-dev在很多教程里看是最快的路径但它有几个很实际的问题不是不能跑而是跑起来之后你会不断遇到限制。版本陈旧是最直接的一个问题。Ubuntu的软件源里GTest版本往往滞后于上游。比如老的Ubuntu 18.04上libgtest-dev还停留在GTest 1.8.x那时候还不支持GTEST_SKIP()这类现代语法。假如你的代码里用了新版API或者你写测试时想用较新的匹配器风格直接链接系统包就会报编译错误而且错误信息容易误导你以为是自己的代码写错了。上游早就在新版本里解决的问题折腾你好几个小时非常不划算。系统的libgtest-dev不一定会帮你安装CMake配置文件。这一点做CMake项目集成时特别痛苦。你知道find_package(GTest)在CMake里很好用但如果系统包只把头文件和静态库放到了/usr/include和/usr/lib却没有生成对应的GTestConfig.cmake那find_package就找不到东西。很多老教程是用手写路径来链接的时间一长大家都不知道标准接法是什么了。自己编译安装GTest时CMake的配置文件会一并生成并安装到约定位置后面find_package(GTest)就能稳稳地工作。你无法控制ABI和构建选项。单元测试库需要和被测试的代码保持编译器、C标准、甚至某些宏定义的一致性。你自己用CMake构建时可以控制CMAKE_BUILD_TYPE可以决定编静态库还是动态库可以决定要不要顺手编GMock库。系统包则是“给你什么就用什么”一旦你项目里开了较新的C标准而系统库是旧编译器产出的就可能在链接阶段出现奇怪的符号缺失或段错误。这种ABI层面的坑排查起来比业务代码的Bug难十倍。自己编译也是一次很好的动手训练。编译GTest的过程会触及CMake的基本用法、源码目录组织、构建选项、安装路径等知识。你把这些跑通了以后给OpenCV、gRPC这类大型库做定制编译时思路完全一样。与其说这是在“折腾一个测试库”不如说是在练一套通用的CMake编译方法论。所以说到底自己编译GTest不是在重复造轮子而是让你掌握主动权版本自己挑编译选项自己定安装目录自己规划出了问题自己能排查。下面我会把整个过程拆开讲。2. 动手编译前先把GTest源码结构和版本选型搞清楚我第一次编译GTest时直接clone了整个仓库进根目录就执行cmake ..结果发现构建时看到一堆GoogleMock的输出心里还在想是不是拉错了仓库。其实这就是GTest源码的正常结构它本来就是GTest和GMock放在一个仓库里维护的。2.1 GTest和GMock的关系GoogleTest的官方仓库googletest根目录下主要有两个子项目googletest也就是我们平时说的GTest本体提供TEST、TEST_F、EXPECT_EQ、ASSERT_NE这类断言和测试框架能力。它的源码在googletest/src头文件在googletest/include/gtest。googlemock即GMock基于GTest实现的C模拟Mock框架用来生成假对象、验证调用行为。它的源码在googlemock/src头文件在googlemock/include/gmock。仓库根目录的CMakeLists.txt会把两个子项目一起构建。所以当你用根目录做CMake编译时出来的库既有libgtest也有libgmock这是正常现象。GMock本身依赖GTest它内部已经处理好了两个库之间的依赖关系。理解这个结构对你后面做项目集成很重要。很多人习惯只链接gtest和gtest_main等哪天想用GMock写MOCK_METHOD时发现符号找不到才回头去补库。其实从一开始编译时就直接打开GMock选项一劳永逸。2.2 版本选型建议GTest的版本迭代不算激进但不同版本之间的CMake行为、安装路径规则确实有差异。我的建议是选最近的稳定tag不要用master分支。master可能引入了新的改动短期看能尝鲜长期看会给你埋雷。目前比较稳妥的选择是v1.14.0或更新的v1.16.x这类release版本。选版本时注意一点如果你们项目组有多个模块都在用GTest版本最好统一。我在实际工作中见过A模块用v1.10.0B模块用v1.14.0两个模块同时进一个测试二进制的时候就会出现重复的符号定义或者行为不一致。GTest官方其实希望你在一个项目里只使用一份GTest版本分裂等于自己给自己加调试难度。2.3 下载源码的几种方式最简单的自然是git clonegit clone --depth 1 -b v1.14.0 https://github.com/google/googletest.git--depth 1表示只拉取当前tag的代码不拉历史记录速度快很多。如果你的网络访问GitHub不太畅快可以找公司内部镜像或者使用非官方中转源但一定要确认下载下来的压缩包校验值别拿个被篡改的源码来做测试库。下载完源码后先别急着编建议顺手看一眼根目录的CMakeLists.txt确认一下这个版本默认开启了哪些构建选项。这个习惯能帮你省掉后面很多困惑。3. 完整编译与安装从CMake配置到make install的一次走通下面给出我实际操作中一直在用的完整编译流程。这一套在Ubuntu 20.04、22.04上都可以直接跑通。# 1. 先确认基础工具链没问题 sudo apt update sudo apt install -y build-essential cmake git # 2. 拉取GTest源码 git clone --depth 1 -b v1.14.0 https://github.com/google/googletest.git cd googletest # 3. 配置CMake构建 cmake -S . -B build \ -DCMAKE_BUILD_TYPERelease \ -DBUILD_GMOCKON \ -DINSTALL_GTESTON # 4. 编译 cmake --build build -j$(nproc) # 5. 安装到系统 sudo cmake --install build3.1 一步步解释每个选项-DCMAKE_BUILD_TYPERelease测试框架库本身用Release编译是为了减少运行时开销。单元测试框架在测试代码中承担的是断言和调度职责如果库本身带了太多调试信息或没开启优化跑大量用例时性能会有一定损耗。我们想测的是业务代码不是测试框架自己。-DBUILD_GMOCKON同时构建GMock库。前面说过根目录项目会把GTest和GMock一起编这里显式打开更保险。GTest官方版本的默认值就是ON但你显式指定将来升级CMake或者换版本时行为不会意外变化。-DINSTALL_GTESTON控制是否执行安装规则。新版本GTest默认会生成install规则但老版本的GTest默认是不安装的这也是很多人“编译完发现库文件到处找不到”的原因。显式打开后cmake --install才会把头文件、库文件、CMake配置文件放到系统目录。3.2 编译产物验证编译完成后先看一眼产出的文件ls build/lib # 你会看到类似 # libgtest.a # libgtest_main.a # libgmock.a # libgmock_main.a默认情况下这些是静态库。如果你需要动态库可以在CMake配置时加上-DBUILD_SHARED_LIBSON这样会生成对应的.so文件。但说真的对单元测试这种场景我建议用静态库。理由后面踩坑部分会详细说简单讲就是静态库把测试框架整个编进了测试二进制运行时不依赖环境里的LD_LIBRARY_PATH也不会出现“测试在你这能跑到CI机器上就加载不到so”的诡异问题。安装完成后检查这两个路径ls /usr/local/include/gtest ls /usr/local/lib | grep gtest如果能看到libgtest.a和头文件目录说明安装成功。还有一个容易被忽略的东西——安装时会一道生成CMake的包配置文件通常在/usr/local/lib/cmake/GTest或/usr/local/lib/cmake/GoogleTest下。这个配置文件决定了find_package(GTest)能不能在你的CMake项目里被正确找到。看到这个目录存在你就放心了。4. 在项目里集成编译好的GTest三种CMake接法及其适用场景GTest库编好了接下来是怎么把它接进你的C项目。这步我见过太多人卡住主要是三种方法混着用出了问题又说不清自己在用哪种。这里一次把三种主流方式拉通讲清楚。4.1 方式Aadd_subdirectory直接引用源码如果你不想安装库只想在项目里临时用一下GTest源码可以在项目根目录放一个third_party/googletest目录然后在你的CMakeLists.txt里写cmake_minimum_required(VERSION 3.14) project(MyProject CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) enable_testing() add_subdirectory(third_party/googletest) add_executable(my_test test_main.cpp test_demo.cpp) target_link_libraries(my_test PRIVATE gtest gtest_main gmock gmock_main)这种方式的好处是不用安装整个构建完全放在你的项目内。缺点是每次编你的项目都会把GTest源码重新编译一遍整体构建时间会长一些而且add_subdirectory会把GTest的很多目标暴露出来容易和项目内其他target重名。实际项目里我更多把它用在临时验证场景。4.2 方式BFetchContent自动拉取如果项目组希望不把第三方源码放进自己的Git仓库但又希望每次构建自动拿到指定版本的GTest用FetchContent是目前最推荐的cmake_minimum_required(VERSION 3.14) project(MyProject 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(my_test test_main.cpp test_demo.cpp) target_link_libraries(my_test PRIVATE gtest gtest_main gmock gmock_main)FetchContent_MakeAvailable会自动在配置阶段拉取源码、加子目录、定义target。好处是版本固定、无需手动安装、新成员克隆项目后直接能编。缺点是第一次配置时要访问远程仓库网络差的时候会比较痛苦。但完全可以把googletest这一行依赖写进项目的初始化脚本提前clone到本地缓存。4.3 方式Cfind_package查找安装好的库这就是我们前面辛辛苦苦编译、安装的最终归宿。既然已经sudo cmake --install build装到了/usr/local项目里就可以清爽地这样写cmake_minimum_required(VERSION 3.14) project(MyProject CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) enable_testing() find_package(GTest REQUIRED) add_executable(my_test test_main.cpp test_demo.cpp) target_link_libraries(my_test PRIVATE GTest::gtest GTest::gtest_main GTest::gmock GTest::gmock_main) include(GoogleTest) gtest_discover_tests(my_test)这里有两个重点GTest::gtest这种带命名空间的目标名是CMake从GTest安装时生成的GTestConfig.cmake里拿到的。如果你发现find_package报错找不到包大概率是安装路径不在CMake搜索范围里。可以用-DCMAKE_PREFIX_PATH/usr/local指定或者设置环境变量GTest_ROOT。如果是自己安装到非默认前缀这两个手段是最常用的修复方式。gtest_discover_tests是include(GoogleTest)之后CMake提供的一个函数。它会在构建完成后读取测试二进制里注册的所有用例自动帮你组建CTest的测试列表。这样做的好处是你每次新增一个TEST用例不需要在CMakeLists里再手动添加一条add_test命令。记住如果只是find_package而忘了include(GoogleTest)gtest_discover_tests是不存在的。三种方式我都实际跑过对比结论如下方式版本控制首次配置耗时安装依赖适用场景add_subdirectory依赖你仓库里的源码快无临时验证、不想装库FetchContent通过GIT_TAG固定较慢无项目团队协作find_package依赖安装版本快需要CI环境、系统级共享库我个人在正式项目里最常用方式C因为安装后的库能复用于多个项目编译速度也快。但如果你们团队的项目是整套独立交付不想污染系统的/usr/local那方式B是最稳妥的。5. 写一个真实可跑的单元测试构建、运行与命令行玩法库编好了CMake也接进去了接下来就是让它真正跑起来。我写一个非常简单的例子来演示整个链路包含被测类和测试文件。5.1 被测代码新建一个calculator.h#ifndef CALCULATOR_H #define CALCULATOR_H #include stdexcept class Calculator { public: int Add(int a, int b) { return a b; } int Divide(int a, int b) { if (b 0) { throw std::invalid_argument(divide by zero); } return a / b; } }; #endif5.2 测试文件新建test_calculator.cpp#include gtest/gtest.h #include calculator.h TEST(CalculatorTest, AddWorks) { Calculator calc; EXPECT_EQ(calc.Add(1, 2), 3); EXPECT_EQ(calc.Add(-1, 1), 0); } TEST(CalculatorTest, DivideNormalCase) { Calculator calc; EXPECT_EQ(calc.Divide(10, 2), 5); } TEST(CalculatorTest, DivideByZeroThrows) { Calculator calc; EXPECT_THROW(calc.Divide(1, 0), std::invalid_argument); }这里的TEST宏有两个参数第一个是测试套件名第二个是用例名。同一套件下的测试共享SetUp和TearDown逻辑但每个用例本身是独立执行的。GTest还提供了TEST_F配合继承testing::Test的夹具类来自定义每个用例前初始化环境。上面这段还没用夹具属于最基础的写法。5.3 测试入口你可以选择自己写main函数#include gtest/gtest.h int main(int argc, char **argv) { ::testing::InitGoogleTest(argc, argv); return RUN_ALL_TESTS(); }如果不想写mainGTest还提供了gtest_main库里面已经定义好了标准的main函数。链接时带上GTest::gtest_main或gtest_main即可。上一章CMakeLists里我已经用gtest_discover_tests的方式把测试注册到了CTest这里选择用gtest_main直接省掉main文件。实操里我建议正式项目还是自己写一个test_main.cpp方便统一处理一些全局初始化逻辑比如命令行参数、环境变量清理之类。5.4 构建与运行cmake -S . -B build cmake --build build -j$(nproc) ./build/my_test成功时输出大概是这样的[] Running 3 tests from 1 test suite. [----------] Global test environment set-up. [----------] 3 tests from CalculatorTest [ RUN ] CalculatorTest.AddWorks [ OK ] CalculatorTest.AddWorks (0 ms) [ RUN ] CalculatorTest.DivideNormalCase [ OK ] CalculatorTest.DivideNormalCase (0 ms) [ RUN ] CalculatorTest.DivideByZeroThrows [ OK ] CalculatorTest.DivideByZeroThrows (0 ms) [----------] 3 tests from CalculatorTest (0 ms total) [----------] Global test environment tear-down [] 3 tests from 3 test suites ran. (0 ms total) [ PASSED ] 3 tests.如果用ctest跑效果也是一样的而且CTest会输出统一的测试汇总cd build ctest --output-on-failuregtest_discover_tests已经帮我们把3个用例都注册成CTest的独立测试项了所以你能在CTest的输出里逐个看到它们。5.5 命令行参数玩法GTest的测试二进制自带一组命令行开关排查问题的时候非常有用# 只看某个套件的用例 ./build/my_test --gtest_filterCalculatorTest.AddWorks # 运行匹配通配符的用例 ./build/my_test --gtest_filterCalculatorTest.*:OtherTest.* # 列出所有用例但不运行 ./build/my_test --gtest_list_tests # 跑10次找偶发失败 ./build/my_test --gtest_repeat10 # 首次失败立即中断适合调试 ./build/my_test --gtest_break_on_failure # 控制输出颜色CI日志里很有用 ./build/my_test --gtest_coloryes--gtest_filter支持:分隔多个模式*是通配符-前缀表示排除。比如--gtest_filter*.*:-FlakyTest.*这种写法配合--gtest_repeat加大循环次数是定位偶发问题最常用的一套组合。5.6 ASSERT和EXPECT的区别写断言时ASSERT_*和EXPECT_*看起来差不多但行为很不同。EXPECT_EQ(a, b)失败时记录失败信息但当前用例后面的代码还会继续执行。ASSERT_EQ(a, b)失败时立刻终止当前用例后续代码不再执行。以DivideByZeroThrows为例如果calc.Divide(1, 0)没有抛异常那么EXPECT_THROW会记录一次失败但其他步骤还能往下走。假如你用ASSERT_NE去确认一个对象不为空但其实它是空的那后面的成员访问就会直接段错误。所以指针、资源句柄这类前置条件适合用ASSERT普通的输出值校验用EXPECT更合理一次测试能收集到更多失败信息。6. 编译链接与运行阶段的高频踩坑及完整排查思路这一节是很多人等了一整篇的内容。我自己在Ubuntu下编译GTest、集成到项目时陆陆续续遇到过不少报错。下面挑几个最高频的每个都按“现象、原因、排查、修复”的顺序讲。6.1 CMake自动检测编译器时报错网上有个高频报错长这样CMake Error at /usr/share/cmake-4.2/Modules/CMakeDetermineCompilerId.cmake:9 (message): The C compiler identification is unknown这个错误本质上不是GTest的问题而是系统里根本没有gcc/g或者CC/CXX环境变量指向了一个不存在的编译器。CMake在配置阶段会先编译一个探测程序来确认编译器型号这一步失败了后面所有配置都走不下去。排查链路很简单先执行gcc --version和g --version如果提示找不到命令那就是没装build-essential。直接执行sudo apt update sudo apt install -y build-essential cmake装完重新测试g --version确认输出正常后再回GTest目录重新执行CMake。如果gcc --version正常但CMake还是报错那就检查环境变量echo $CC $CXX有异常设置就临时清掉unset CC CXX这种问题多数是误改了~/.bashrc里的CC配置导致的。6.2 链接时出现pthread相关的undefined reference现象是编译正常链接时报出一堆类似undefined reference to pthread_create undefined reference to pthread_key_createGTest的多线程调度、测试并发功能依赖pthread库。老版本的GTest可能没显式链接-lpthread需要你在自己的CMakeLists.txt里补充。现代CMake推荐这么写find_package(Threads REQUIRED) target_link_libraries(my_test PRIVATE Threads::Threads)把Threads::Threads加进链接目标里比手动写-lpthread更规范因为CMake会帮你选择正确的线程库名称跨平台也不会出错。如果你的CMakeLists没加这行链接阶段出现pthread符号缺失优先加这个。6.3 找不到GTest头文件或库文件报错一般是fatal error: gtest/gtest.h: No such file or directory或者链接时报cannot find -lgtest分两种情况排查。第一种是你用find_package(GTest REQUIRED)但CMake没找到包配置阶段就会早早报错。这时检查是否把GTest安装到了非默认前缀。如果是/usr/local但CMake没找到多半是系统版本较老CMake搜索路径不包含/usr/local/lib/cmake手动指定cmake -S . -B build -DCMAKE_PREFIX_PATH/usr/local第二种是你成功find_package但编译时还是找不到头文件。这通常是因为安装出来的头文件目录结构和你预期不一样可以看看/usr/local/include下是否存在gtest目录。如果用了旧版GTest头文件可能散落在/usr/include/gtest。自己明确定位一下头文件实际位置再在CMakeLists里用target_include_directories手动补充即可。6.4 动态库与静态库混用引发的运行时找不到so如果你的GTest编成了动态库安装后把编译好的测试二进制拷贝到另一台机器去跑可能运行时就会提示error while loading shared libraries: libgtest.so: cannot open shared object file这类问题的根因是编译器链接时能找到so但运行时动态加载器找不到。排查时执行ldd ./build/my_test | grep gtest看到not found就说明加载路径不对。解决办法有几种最省事的是改用静态库。默认不设置BUILD_SHARED_LIBS时编译产出的就是.a文件链接进二进制后不再依赖外部so。如果确实需要动态库把/usr/local/lib加入动态加载路径修改~/.bashrc并执行export LD_LIBRARY_PATH/usr/local/lib:$LD_LIBRARY_PATH但不能只改当前终端CI环境也要同步。或者用ldconfig配置系统级的库缓存在/etc/ld.so.conf.d/下新建gtest.conf写入/usr/local/lib然后执行sudo ldconfig。我个人的默认选择始终是静态库避免一切运行时路径问题。6.5 add_subdirectory时出现target重名当你的项目里已经有一个gtest_main目标和GTest源码里定义的gtest_main撞名CMake会报CMake Error at ...: add_library cannot create target gtest_main because another target with the same name already exists.这种情况多发生在一不小心把GTest的add_subdirectory加进了多个模块或者项目里本身有个叫gtest_main的库。排查思路是看CMakeLists里有没有重复引入GTest源码的语句以及项目内有没有自定义的gtest_main库。修复方法也很直接项目内的自定义库改名或者不要用add_subdirectory方式改用FetchContent/find_package方式让GTest以命名空间目标的方式存在冲突概率大幅下降。6.6 Qt项目里常见的“cannot find -lxxx”类链接错误有时候问题不在GTest本身而是你的CMake项目里还挂着Qt或其他库的链接。网上经常见到这种报错cannot find -lpublic看起来是缺一个叫public的库实际检查项目CMakeLists后往往发现是在写Qt模块链接时不小心把PUBLIC这个关键字写错了位置或者把文件路径写成了链接库名。这个排查思路对GTest也有借鉴意义链接报错时先去看看要链接的到底是什么再判断它是路径问题、拼写问题还是依赖缺失。不要一上来就去装一个莫名其妙的libpublic库那样只会把环境搞得更乱。6.7 排查思路总结梳理一下遇到编译链接类问题我的固定排查链路是先复现把完整错误信息贴到搜索工具里但不要只看第一行要看最下面的undefined reference或cannot find那几行那里才有真正缺的信息。区分阶段是CMake配置阶段报错还是编译阶段报错还是链接阶段报错三个阶段的处理方式完全不同。检查硬前提g --version、cmake --version、ls /usr/local/include/gtest、ls /usr/local/lib/libgtest*确保依赖环境健全。二分定位如果是链接问题可以在CMakeLists里逐个注释掉target_link_libraries里的库看哪个库注释后不再报错那报错基本上就是它引起的。修复后回测先跑最小用例确认修复有效再把它纳入常见问题清单。这套链路我用了很多年绝大多数第三方库的编译链接困扰都能在二十分钟内定位清楚。关键不是背错误码而是养成“分阶段排查、验证假设”的思维习惯。最后再分享一点经验实际项目里经过这一整套流程我现在最顺手的做法是把GTest固定版本、用find_package方式集成然后顺手把gtest_discover_tests注册进CTest。这样代码库里的测试能统一跑ctestCI里的门槛也清晰。每次新机器拉下来配置开发环境我都会把这几个命令写进团队内部的初始化脚本里保证每个人本地的GTest都是一样的版本、一样的构建选项不留“在我机器上是好的啊”这种隐患。如果你刚开始接触这个组合不必一次把三种集成方式都学会。先用最直接的find_package流程跑通一个小demo把测试加进你的业务代码感受一下改一行代码、加一个测试、看一次红灯变绿灯的循环。后面再慢慢探索GMock、测试夹具、覆盖率这些更高级的能力。工具这东西能顺手解决你当下的问题才值得继续深挖。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门