先说一个我经常被问到的问题:Ubuntu下想用GTest跑单元测试,为什么偏要自己用CMake编译一遍,直接apt install libgtest-dev然后include、链接,不就行了?
如果你也这么想,那这篇文章值得看完。我在多个Ubuntu版本上做过GTest的编译和集成,踩过不少坑,也帮别人排查过很多次类似的问题。自己编译GTest这件事,看起来多此一举,实际上一旦你遇到版本太旧、CMake找不到包、ABI不兼容、链接报出一堆undefined reference这类问题,就会明白直接装系统包的方案有多脆弱。这篇内容适合刚接触Linux下C++开发、准备给项目引入单元测试的同学,也适合已经在用GTest但想彻底搞懂“编译、链接、集成”整条链路的人。我会从“为什么要自己编译”讲起,一直讲到项目里实际跑通测试,再附上实操中高频出现的错误和排查思路。
1. 为什么偏要自己编译GTest:apt装好的库到底差在哪
先回答开头那个问题。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.0,B模块用v1.14.0,两个模块同时进一个测试二进制的时候,就会出现重复的符号定义或者行为不一致。GTest官方其实希望你在一个项目里只使用一份GTest,版本分裂等于自己给自己加调试难度。
2.3 下载源码的几种方式
最简单的自然是git clone:
git 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_TYPE=Release \ -DBUILD_GMOCK=ON \ -DINSTALL_GTEST=ON # 4. 编译 cmake --build build -j$(nproc) # 5. 安装到系统 sudo cmake --install build3.1 一步步解释每个选项
-DCMAKE_BUILD_TYPE=Release:测试框架库本身用Release编译,是为了减少运行时开销。单元测试框架在测试代码中承担的是断言和调度职责,如果库本身带了太多调试信息或没开启优化,跑大量用例时性能会有一定损耗。我们想测的是业务代码,不是测试框架自己。
-DBUILD_GMOCK=ON:同时构建GMock库。前面说过,根目录项目会把GTest和GMock一起编,这里显式打开更保险。GTest官方版本的默认值就是ON,但你显式指定,将来升级CMake或者换版本时行为不会意外变化。
-DINSTALL_GTEST=ON:控制是否执行安装规则。新版本GTest默认会生成install规则,但老版本的GTest默认是不安装的,这也是很多人“编译完发现库文件到处找不到”的原因。显式打开后,cmake --install才会把头文件、库文件、CMake配置文件放到系统目录。
3.2 编译产物验证
编译完成后,先看一眼产出的文件:
ls build/lib # 你会看到类似: # libgtest.a # libgtest_main.a # libgmock.a # libgmock_main.a默认情况下这些是静态库。如果你需要动态库,可以在CMake配置时加上-DBUILD_SHARED_LIBS=ON,这样会生成对应的.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 方式A:add_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 方式B:FetchContent自动拉取
如果项目组希望不把第三方源码放进自己的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 方式C:find_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(); }如果不想写main,GTest还提供了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_filter=CalculatorTest.AddWorks # 运行匹配通配符的用例 ./build/my_test --gtest_filter=CalculatorTest.*:OtherTest.* # 列出所有用例但不运行 ./build/my_test --gtest_list_tests # 跑10次,找偶发失败 ./build/my_test --gtest_repeat=10 # 首次失败立即中断,适合调试 ./build/my_test --gtest_break_on_failure # 控制输出颜色,CI日志里很有用 ./build/my_test --gtest_color=yes--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_create'GTest的多线程调度、测试并发功能依赖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。这样代码库里的测试能统一跑ctest,CI里的门槛也清晰。每次新机器拉下来配置开发环境,我都会把这几个命令写进团队内部的初始化脚本里,保证每个人本地的GTest都是一样的版本、一样的构建选项,不留“在我机器上是好的啊”这种隐患。
如果你刚开始接触这个组合,不必一次把三种集成方式都学会。先用最直接的find_package流程跑通一个小demo,把测试加进你的业务代码,感受一下改一行代码、加一个测试、看一次红灯变绿灯的循环。后面再慢慢探索GMock、测试夹具、覆盖率这些更高级的能力。工具这东西,能顺手解决你当下的问题,才值得继续深挖。