ARTICLE DETAIL

资讯详情

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

Ubuntu下用CMake编译集成GTest的完整实践指南

Ubuntu下用CMake编译集成GTest的完整实践指南 说句实在话我在Ubuntu下用CMake编译GTestGoogle的C测试库这套流程前前后后折腾了不下十遍。从最早只会把源码丢进项目里一起编到后来老老实实按标准步骤编译安装再到现在用FetchContent一行搞定依赖这中间踩过的坑、理清的思路值得专门写一篇梳理清楚。这篇文章不是官方文档翻译更是我当时学习时的操作记录加事后复盘。1. 项目整体思路拆解为什么是GTest为什么用CMake1.1 GTest到底解决什么问题C写测试这件事早期是真的痛苦。你当然可以自己写一个main函数里面放一堆if判断跑完打印一个“All Passed”但项目一复杂这套土办法马上失灵测试越多越难组织、失败信息不直观、没法统一统计覆盖率、临时加一个测试还得手动改main。GTest把这一整套都封装好了你只需要用TEST宏写用例剩下的注册、执行、结果汇总它全部接管。GTest我最喜欢的一点是它的断言体系。比如EXPECT_EQ(1 1, 2)这一行如果挂了它会直接打印出两个表达式的实际值告诉你左边是2右边是3你一眼就知道哪里出了问题。另一点是测试隔离每个TEST用例跑完都会恢复现场一个用例挂了不会拖垮后续的用例。这两点看着基础实际用过就知道它们直接决定了你愿不愿意持续写测试。1.2 为什么选CMake而不是纯Makefile编译GTest这件事用纯Makefile其实也能做但一旦牵扯到依赖GTest的测试项目Makefile的维护成本就开始凸显了。CMake的核心价值在于“描述工程结构而不是描述编译命令”。你写清楚有哪些源文件、要链接什么库CMake自动帮你处理头文件路径、系统检测和跨平台差异。尤其GTest这个库它本身推荐的方式就是从源码用CMake构建和CMake的契合度天然很高。而且现在find_package(GTest)、FetchContent、gtest_discover_tests这些CMake模块几乎就是官方为“CMake GTest”组合量身定做的。所以这是一个双赢的选择GTest用CMake编译最省心你的测试项目用CMake组织最省心。1.3 这个方案适合谁参考如果你正在学C或者刚接手一个有测试需求的项目又或者从事嵌入式方向想给自己的基础库补上单元测试这篇文章的思路都可以直接用。我会把从安装依赖、编译GTest、创建测试工程到写测试用例、排查问题这一整条链路全部走一遍。2. Ubuntu下准备环境和获取GTest库2.1 前置工具和系统版本我的操作环境是Ubuntu 22.04但下面这套流程在20.04和24.04上也都适用。开始之前先把基础工具链补全sudo apt update sudo apt install build-essential cmake gitbuild-essential会装好gcc、g和makecmake装构建工具git用于拉取源码。如果你的机器上已经装过其中一部分apt会自动跳过不要紧。这里有个细节很多人会忽略确认一下g和cmake的版本。我建议g不低于9CMake不低于3.16。太老的版本不是说不能用而是新版GTest对编译器有要求老g对一些C14/17语法支持得不好编译过程中会冒出一堆莫名其妙的报错。查看版本很简单g --version cmake --version2.2 两种获取GTest的方式对比获取GTest库有两条主流路线一是用apt直接装官方包二是从源码编译安装。两条我都试过说下它们各自的适用场景。apt方式是最快的sudo apt install libgtest-dev但请注意这条命令装进去的其实是源码还有一个要注意的点是不同Ubuntu版本里libgtest-dev的版本差异很大。而且它默认不带gmock如果你后面想用Google Mock来做打桩测试还得另外配。另一个问题是它头文件存放在/usr/include/gtest但库文件不一定自动生成很多发行版需要你手动进入目录用cmake编译一次。这点非常反直觉我第一次用apt装完直接链接报了一堆“找不到-l gtest”的错就是因为在apt安装后少了这一步手动编译。源码编译则完全没有这些问题。版本自主可控想用release-1.14.0还是更新的版本都由自己定可以编出带调试符号的版本后续用gdb追踪测试代码时非常方便如果你想改GTest内部的某段逻辑做特殊定制也只有源码编译这条路可行。所以我的建议很直接如果你的项目只是临时跑几个简单测试用apt省事但只要稍微正式一点都建议走源码编译。下文也重点讲这条路。2.3 源码编译GTest的完整步骤先从GitHub拉取代码。我习惯固定到一个release tag而不是直接拉master分支这样可控性更好不会因为上游更新把构建搞挂git clone https://github.com/google/googletest.git cd googletest git checkout release-1.14.0截至写这篇文章的时间release-1.16.0已经发布但release-1.14.0对绝大多数项目完全够用。稳定压倒一切测试框架本身不需要追新。接下来用CMake构建cmake -S . -B build -DCMAKE_BUILD_TYPERelease -DBUILD_GMOCKON cmake --build build --parallel $(nproc)这里解释几个关键参数-DCMAKE_BUILD_TYPERelease编译Release版本测试库本身不追求调试Release模式下运行测试更快。如果你需要调试测试代码和GTest内部逻辑也可以编Debug版本但库文件就得和主工程保持一致的构建类型否则混合链接容易出问题。-DBUILD_GMOCKON把Google Mock一起编出来。你可能暂时用不到mock但编出来有备无患链接时用不到也不会造成任何负担。--parallel $(nproc)nproc是获取CPU核心数的命令告诉CMake并行编译能明显加快速度。如果你的机器内存紧张可以把这个参数改成4或者2避免编译时内存被打满。构建完成后安装到系统目录sudo cmake --install build这一步默认会把头文件装到/usr/local/include库文件装到/usr/local/lib。你可以用ls验证一下ls /usr/local/lib | grep gtest ls /usr/local/include | grep gtest正常情况下你能看到libgtest.a、libgtest_main.a、libgmock.a、libgmock_main.a以及include下的gtest和gmock两个目录。2.4 安装后的环境校验GTest库安装完但没配置好环境是最容易出现“链接时正常、运行时找不到”问题的环节。如果你和我一样把库装到了/usr/local/lib先检查这个目录是不是在系统的动态库搜索路径里ldconfig -p | grep gtest如果没有任何输出说明ldconfig没有加载到/usr/local/lib。你可以临时设置环境变量export LD_LIBRARY_PATH/usr/local/lib:$LD_LIBRARY_PATH但更一劳永逸的办法是把它写进配置里echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/usr-local-lib.conf sudo ldconfig这一步做完你后续编译任何依赖GTest的工程都不会遇到“运行测试时提示libgtest.so找不到”的低级错误。3. 用CMake组织GTest测试工程3.1 最小测试项目的目录结构GTest库编好了下一步就是写一个测试工程来用它。我习惯把测试代码和被测代码分层管理目录结构像这样calculator/ ├── CMakeLists.txt ├── include/ │ └── calculator.h ├── src/ │ └── calculator.cpp └── tests/ ├── CMakeLists.txt └── test_calculator.cpp被测代码是一个简单的计算器类头文件长这样// include/calculator.h #pragma once class Calculator { public: int add(int a, int b); int subtract(int a, int b); };实现文件// src/calculator.cpp #include calculator.h int Calculator::add(int a, int b) { return a b; } int Calculator::subtract(int a, int b) { return a - b; }这个类简单到一眼能看穿但用它来演示GTest的用法足够了。3.2 顶层CMakeLists.txt的写法项目根目录的CMakeLists.txt负责组织整体结构cmake_minimum_required(VERSION 3.16) project(calculator LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(calculator STATIC src/calculator.cpp ) target_include_directories(calculator PUBLIC include) enable_testing() add_subdirectory(tests)这里有个细节我把被测代码编成一个静态库测试代码再去链接它。这样做的好处是测试文件和被测源码解耦后续如果给calculator加更多功能测试工程不需要改构建结构只需要增加新的测试文件。enable_testing()这行很关键。没有它后面的ctest命令根本不会生效有了它你在build目录里执行ctest就能统一运行所有注册过的测试。3.3 测试目录的CMakeListsfind_package方案tests/CMakeLists.txt有两种常见写法第一种是find_package方案find_package(GTest REQUIRED) add_executable(test_calculator test_calculator.cpp) target_link_libraries(test_calculator PRIVATE calculator GTest::gtest GTest::gtest_main ) include(GoogleTest) gtest_discover_tests(test_calculator)find_package(GTest REQUIRED)会在系统的CMake模块路径中查找GTest的配置文件。我们在前面用cmake --install把库装到了/usr/local这一句能找到它。target_link_libraries这里GTest::gtest这个名字看起来有点抽象其实它对应的是libgtest库而GTest::gtest_main对应libgtest_main库。后者提供了一个现成的main函数入口它内部初始化GTest并运行所有注册过的测试用例。如果你的测试工程里每个测试文件都要自己写main那用gtest_main就能省掉这一步。对于每个test_xxx文件除了gtest之外还需要链接被测的calculator库。如果忘记链接编译阶段不报错链接阶段就会报一堆undefined reference对你写了声明没写定义的函数。我见过很多新手卡在这一步。gtest_discover_tests(test_calculator)是CMake 3.10版本以后引入的宏它会在构建时自动扫描这个测试可执行文件里的每个TEST用例逐个注册到ctest中。这个宏带来的便利是你以后在测试文件里新增一个用例不需要去改CMakeLists.txt添加一行add_test重新构建完直接ctest就能看到新用例。3.4 更灵活的FetchContent方案如果你不想把GTest安装到系统目录而是希望每个项目独立拉取一个版本那用FetchContent更合适。它的写法如下include(FetchContent) FetchContent_Declare( googletest GIT_REPOSITORY https://github.com/google/googletest.git GIT_TAG release-1.14.0 ) FetchContent_MakeAvailable(googletest) add_executable(test_calculator test_calculator.cpp) target_link_libraries(test_calculator PRIVATE calculator gtest_main )FetchContent会在第一次配置项目时自动clone指定版本的GTest源码并把它作为子项目编进你的构建体系。这种方式的好处是团队里每个人拉完代码都能直接构建不需要先手动安装任何系统级依赖不同的项目也可以用不同版本的GTest互不冲突。但它也有明显的短板首次cmake配置时耗时明显因为要现场拉取并编译整个GTest如果你的构建环境无法访问GitHub那这种方式就完全不可行。对于开源项目或者追求可重现构建的场景FetchContent是首选对于公司内网环境或者追求最小依赖的场景find_package方案更稳妥。两种方案实际效果一样都能跑起测试区别在于依赖管理的哲学。我在自己玩的开源项目里用FetchContent在公司的规范项目里用find_package你们根据实际环境选就行。3.5 实际构建与运行测试构建过程非常简单cmake -S . -B build cmake --build build --parallel $(nproc) cd build ctest第一次执行cmake -S . -B build时CMake会检测编译器、读取CMakeLists.txt、生成Makefile或Ninja文件。如果你的环境中既有gcc又有clangCMake默认会优先选择系统默认的gcc这一点在排查问题时需要注意。ctest输出长这样Test project /home/user/calculator/build Start 1: test_calculator 1/2 Test #1: test_calculator .............. Passed 0.01 sec如果测试失败ctest会显示失败信息并且退出码非0。在CI脚本里可以利用这个特性做自动化判断。但ctest只是简略展示看不到每个测试用例的明细。想看GTest的完整输出两种办法一是直接在终端运行测试可执行文件./tests/test_calculator二是用ctest的详细输出模式ctest --output-on-failure平时本地调试验证我习惯直接跑可执行文件集成到CI里用ctest。两条路都要熟。4. 写第一组测试用例理解GTest断言体系4.1 从TEST宏开始测试文件tests/test_calculator.cpp第一版长这样#include gtest/gtest.h #include calculator.h TEST(CalculatorTest, Add) { Calculator calc; EXPECT_EQ(calc.add(1, 2), 3); EXPECT_EQ(calc.add(-1, 1), 0); EXPECT_EQ(calc.add(0, 0), 0); } TEST(CalculatorTest, Subtract) { Calculator calc; EXPECT_EQ(calc.subtract(5, 3), 2); EXPECT_EQ(calc.subtract(3, 5), -2); }TEST宏接收两个参数第一个是测试套件名Test Suite第二个是用例名Test Case。两个用例Add和Subtract都属于CalculatorTest这个套件。这里有个命名上的提醒测试套件名不要用点号或者下划线。GTest内部会把这两个名字拼成一个完整的测试名例如CalculatorTest.Add。如果套件名带了点生成的内部标识符会变得很怪GTest甚至会直接报“改用自己的模板参数别带点”的建议。我最初用gtest_test_1.test_a这种命名方式踩过这个坑。4.2 EXPECT与ASSERT的差异GTest的断言家族分两派。一派是EXPECT_断言失败时打印错误信息、标记当前用例为失败但测试不会立即停下来而是继续往下执行另一派是ASSERT_一旦失败整个当前用例立即终止。这个区别在实际调试中非常关键。拿上面的Add用例举例如果把第一个EXPECT_EQ换成ASSERT_EQ那么当calc.add(1, 2)返回的不是3时后面两个断言就不会执行。对于“后面的断言依赖前面结果”的模式比如先取值再判断用ASSERT能避免后续断言产生误导性的二次报错对于单纯的多组独立数据校验用EXPECT则可以一次性把所有问题都暴露出来。我个人的习惯是前置条件校验用ASSERT结果正确性校验用EXPECT。比如从一个容器里取元素先ASSERT_TRUE(iter ! vec.end())再EXPECT_EQ(*iter, 42)。这样逻辑清晰报错也干净。4.3 常用断言速查除了EXPECT_EQ还有一些高频断言建议背下来EXPECT_TRUE(condition); EXPECT_FALSE(condition); EXPECT_NE(a, b); // 不等于 EXPECT_LT(a, b); // 小于 EXPECT_LE(a, b); // 小于等于 EXPECT_GT(a, b); // 大于 EXPECT_GE(a, b); // 大于等于 EXPECT_FLOAT_EQ(a, b); // 浮点数近似相等 EXPECT_NEAR(a, b, 0.01); // 浮点数误差范围内相等 EXPECT_THROW(statement, exception_type); // 断言抛出特定异常 EXPECT_DEATH(statement, regex); // 断言进程死亡这里EXCEPT_FLOAT_EQ和EXPECT_NEAR是浮点比较专用。C浮点数直接比较相等极不靠谱因为二进制表示误差的存在让1.0/3.0*3.0不一定等于1.0。GTest的EXPECT_FLOAT_EQ内部会用ULPUnit in the Last Place策略判断两个浮点数的误差是否在可接受范围内这个比手工比较abs(a-b) 1e-6要科学不少。4.4 测试夹具TEST_F的使用场景多个测试用例需要共用同一套初始化逻辑时TEST_F就派上用场了。比如每个用例都需要一个特定初始状态的Calculator与其在每个TEST里手动写一遍初始化不如建一个夹具类class CalculatorTestFixture : public ::testing::Test { protected: void SetUp() override { calc.set_offset(10); } void TearDown() override { // 每个测试用例结束后的清理逻辑 } Calculator calc; }; TEST_F(CalculatorTestFixture, AddWithOffset) { EXPECT_EQ(calc.add(1, 2), 13); } TEST_F(CalculatorTestFixture, SubtractWithOffset) { EXPECT_EQ(calc.subtract(5, 3), 12); }TEST_F的语义是每个测试用例执行前GTest都会创建一个新的夹具对象调用SetUp然后运行你写的用例体用例结束调用TearDown销毁夹具对象。这意味着不同用例之间数据完全隔离再也不用担心上一个测试改的状态影响下一个。一个很重要的细节TEST_F的第一个参数必须是夹具类名而且这个夹具类必须继承::testing::Test。第二个参数才是用例名。如果你把TEST_F写成TEST(CalculatorTestFixture, ...)编译器会喜滋滋地报一个类型错误因为普通TEST把第一个参数当套件名处理根本不知道夹具类长啥样。4.5 参数化测试与运行参数最后提一下参数化测试。当你要对同一个功能用多组数据做测试时最朴素的写法是复制粘贴多个EXPECT或者写一个循环。但这两种方式都有问题复制粘贴导致报错时不知道具体哪组数据失败循环则一旦失败只显示一次错误不知道是哪一组输入引发的。GTest提供TEST_P配合INSTANTIATE_TEST_SUITE_P实现真正的参数化测试class CalculatorParamTest : public ::testing::TestWithParamstd::tupleint, int, int { protected: Calculator calc; }; TEST_P(CalculatorParamTest, AddMultiple) { int a std::get0(GetParam()); int b std::get1(GetParam()); int expected std::get2(GetParam()); EXPECT_EQ(calc.add(a, b), expected); } INSTANTIATE_TEST_SUITE_P( AddCases, CalculatorParamTest, ::testing::Values( std::make_tuple(1, 1, 2), std::make_tuple(2, 3, 5), std::make_tuple(-1, 1, 0), std::make_tuple(100, 200, 300) ) );跑起来之后GTest会把这四组数据当成四个独立用例执行从输出里你能明确看到AddMultiple/AddCases_test_0这样的命名哪组挂了直接定位。运行测试时GTest还能接受一批运行时参数其中GTest自带的过滤选项最实用./tests/test_calculator --gtest_filterCalculatorTest.* ./tests/test_calculator --gtest_filter*Add*:*Subtract* ./tests/test_calculator --gtest_repeat10--gtest_repeat10这个我特别推荐用来排查偶现问题比如测试依赖于系统时间或者随机数重复跑十次能大幅提高抓出问题的概率。5. 常见问题与排查技巧实录5.1 CMake找不到GTest包最常见的报错是这样CMake Error at CMakeLists.txt:xx (find_package): Could not find a package configuration file provided by GTest多数情况是find_package搜索路径里没有我们编译好的GTest。排查路径如下先确认/usr/local/lib/cmake/GTest或者/usr/local/lib/cmake/Googletest目录存在。如果不存在说明前面cmake --install没有执行成功或者安装到了其他prefix。这时可以显式指定搜索路径set(CMAKE_PREFIX_PATH /usr/local ${CMAKE_PREFIX_PATH}) find_package(GTest REQUIRED)还有一类特殊情况你的系统里同时存在多个GTest版本。比如apt装过一份老版本在/usr/include源码编译装了一份新版本在/usr/local/include。此时CMake搜索到哪个取决于路径顺序头文件和库文件可能来自不同版本编译报错一头雾水。建议把apt装的libgtest-dev卸载干净只保留源码编译的版本。5.2 链接错误undefined reference链接阶段报undefined reference最常见的断句是我明明写了函数实现为什么还是找不到这里有一个很典型的混淆。链接错误和编译错误的区别要拎清楚。编译错误是语法层面或类型层面说明你写的代码有问题链接错误是符号找不到说明代码声明存在但实现没被链进来。所以undefined reference出现时先不要怀疑代码逻辑先检查CMakeLists里target_link_libraries写没写全。举一个具体例子你的测试文件里调用calc.add但tests/CMakeLists.txt里只链了GTest::gtest忘了链calculator库。此时编译器能正常编译test_calculator.cpp因为头文件里声明了add但链接器在生成可执行文件时找不到add的实现直接报错。解决方式很简单target_link_libraries(test_calculator PRIVATE calculator GTest::gtest GTest::gtest_main )另一个常见变体是没有链接pthread。GTest内部用了多线程如果你的项目是在老版本gcc环境编译可能需要链pthread。现在较新的g一般默认就带但保险起见可以在链接选项里加target_link_libraries(test_calculator PRIVATE pthread)5.3 运行时找不到共享库报错信息长这样error while loading shared libraries: libgtest.so: cannot open shared object file: No such file or directory这种情况说明编译链接都成功了但程序启动时加载动态库失败。原因就是我在2.4节强调过的动态库搜索路径问题。如果你的处理方式是源码编译并安装到了/usr/local/lib那一定要保证ldconfig能识别到这个路径。如果在公司多用户服务器上没有sudo权限可以改用用户级配置export LD_LIBRARY_PATH$HOME/.local/lib:$LD_LIBRARY_PATH或者编译的时候直接让可执行文件带上绝对路径的rpath。在CMake里加一行set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)动手之前先搞清楚哪种方式适合你的权限环境这个坑能省掉很多无谓折腾。5.4 GTest版本与CMake版本不兼容新版GTest从某个版本开始抛弃了对旧版FindGTest.cmake模块的支持转而提供自己的CMake package配置文件。如果你项目里写的是老式的include_directories(/usr/include/gtest)配合link_directories(/usr/local/lib)大概率也能编但遇到版本升级就容易出怪异问题。我的建议是紧跟官方推荐路径。要么用find_package(GTest REQUIRED)要么用FetchContent拉源码不要自己手写include_directories和link_directories拼凑路径。刚才写CMakeLists.txt时可能没细说这里重点解释手写路径方式虽然看起来直白但它绕过了CMake的依赖管理和版本检查一旦库版本变化报错信息极其难懂。如果你遇到cmake configure阶段一个奇怪的错误形如CMake Error at /usr/share/cmake-XX/modules/CMakeDetermineCompilerId.cmake:9这和你用不用GTest没关系多半是CMake自身检测编译器出了岔子。常见原因是CC/CXX环境变量指到了不存在的编译器或者刚调整过gcc版本导致环境变量失效。清掉环境变量重新试unset CC CXX cmake -S . -B build5.5 多个可执行文件的测试注册当你测试一个包含多个模块的项目每个模块都生成一个test可执行文件时建议在顶层CMakeLists.txt写一个公共函数来注册测试避免每个子目录重复粘贴代码function(add_gtest target) add_executable(${target} ${ARGN}) target_link_libraries(${target} PRIVATE GTest::gtest GTest::gtest_main ) gtest_discover_tests(${target}) endfunction()然后在tests子目录里这样调用add_gtest(test_calculator test_calculator.cpp) add_gtest(test_logger test_logger.cpp)这个技巧在我们的中型项目里让CMakeLists少了一半行数而且新增测试模块统一走一条路径不容易漏掉gtest_discover_tests。5.6 用Valgrind抓内存问题GTest本身不做内存检测但它跑测试用例的机制很适合配合Valgrind做内存检查。命令很直接valgrind --leak-checkfull ./tests/test_calculatorValgrind会保证每个测试用例结束后检查有无内存泄漏。我踩过最典型的坑是测试类TestFixture的TearDown里忘掉了new出来的指针平时跑测试功能全过但valgrind扫一遍满屏红色。这种问题如果不配合valgrind可能永远发现不了。不过也有个性能代价Valgrind跑测试比正常执行慢十倍以上。所以日常开发就用普通方式跑定期对重大项目跑一遍Valgrind做集成检查。5.7 编译器版本导致的老旧报错旧版g编译新版GTest时最常见的一类报错是涉及C标准库内部模板的“No matching function for call to”之类。这种报错看起来像是GTest代码有问题实际上是你编译器太老对新版标准库特性支持不全。这时两个选择升级g或者改用apt源里的旧版本GTest。我的经验是尽量用g 9以上编译器升级永远是优先选项。另外Ubuntu不同版本默认gcc差异比较大20.04默认gcc 922.04默认gcc 1124.04默认gcc 13。如果你的生产环境是旧版系统又想用新特性可以考虑用update-alternatives切换gcc版本但切换后记得重新执行一次完整构建并且清掉之前的build缓存。不干净的build目录造成的诡异报错比代码本身的问题多得多rm -rf build cmake -S . -B build个人经验是每次升级编译器都建议彻底删掉build目录重新配置省事省心。写在最后的实际操作建议我自己前前后后踩过的最深的坑是在版本匹配和路径搜索上。GTest本身挺好编译难的是你的环境里可能同时存在多份GTestapt装一份源码编译一份项目FetchContent又拉一份。这种情况下头文件和库文件版本不一致往往会出现编译通过、链接失败链接通过、运行失败的诡异组合。所以做任何项目之前先想清楚依赖管理策略全用系统安装或者全用FetchContent两条路都干净最怕混着用。另一个体会是测试命名规范要早点定下来。GTest的测试用例名会在IDE、CI日志和报错信息里反复出现命名清晰一点对定位问题帮助极大。比如模块名_功能名_场景X这样的格式看到报错就能大致猜到哪个功能出了问题。如果这篇文章能帮你在Ubuntu下顺利跑起来第一个GTest用例那我的目的就达到了。剩下的时间建议拿来多写几个测试把EXCEPT_*系列和TEST_F夹具用熟练这些基础打牢了后面写参数化测试、mock测试都会顺很多。
返回列表