60、Google Test:集成GTest,编写和运行单元测试
单元测试这件事,说实话,我入行前三年基本没当回事。总觉得「代码能跑就行,测什么测」。直到有一次,我改了一个工具函数,自以为万无一失,结果上线后把整个系统的配置解析全搞崩了。嗯,从那以后,我再也不敢不写单元测试了。
今天咱们聊的就是 C++ 社区里最主流的测试框架——Google Test,简称 GTest。怎么把它集成到 CMake 项目里,怎么写测试用例,怎么跑起来。我保证,看完这一章,你也能给自己的项目加上「安全网」。
为什么是 GTest?
市面上 C++ 测试框架不少,Catch2、Boost.Test、 doctest 都有。但我个人习惯用 GTest,原因很简单:
- Google 出品,社区活跃——遇到问题 StackOverflow 上一搜一大把
- 断言丰富——从基本比较到异常检查,基本覆盖了所有场景
- 与 CMake 集成丝滑——官方提供了 FetchContent 和 find_package 两种方式
- 支持死亡测试——可以测试「程序应该崩溃」的场景,这个很实用
集成方式:两种主流方案
把 GTest 弄进 CMake 项目,我见过两种主流做法。咱们一个一个说。
方案一:FetchContent(推荐)
这是 CMake 3.14 以后引入的功能。说白了就是让 CMake 在配置阶段自动去 GitHub 下载 GTest 源码,然后编译链接。好处是项目完全自包含,别人 clone 下来直接就能 build。
# CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(MyProject LANGUAGES CXX)
# 启用测试
enable_testing()
# 引入 GTest
include(FetchContent)
FetchContent_Declare(
googletest
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.12.1
)
FetchContent_MakeAvailable(googletest)
# 你的主库
add_library(my_lib src/my_lib.cpp include/my_lib.h)
# 测试可执行文件
add_executable(test_my_lib test/test_my_lib.cpp)
target_link_libraries(test_my_lib PRIVATE my_lib gtest gtest_main)
target_include_directories(test_my_lib PRIVATE ${gtest_SOURCE_DIR}/include)
# 注册测试
add_test(NAME test_my_lib COMMAND test_my_lib)
注意这里链接了 gtest_main,它会自动生成一个 main 函数。如果你自己写了 main,那就只链接 gtest。
URL 参数指向本地路径。
方案二:find_package(系统安装)
如果你在 Linux 上,可以用包管理器安装 GTest,然后通过 find_package 找到它。
# 安装(Ubuntu)
sudo apt install libgtest-dev
# CMakeLists.txt
find_package(GTest REQUIRED)
add_executable(test_my_lib test/test_my_lib.cpp)
target_link_libraries(test_my_lib PRIVATE GTest::GTest GTest::Main)
add_test(NAME test_my_lib COMMAND test_my_lib)
这种方式的好处是编译快,不用每次下载。但缺点也很明显——不同机器上 GTest 版本可能不一样,容易出兼容问题。我个人更倾向 FetchContent,省心。
编写测试用例:从入门到实战
好,集成完了,咱们写几个测试看看。假设我有一个简单的数学工具库:
// include/my_lib.h
#pragma once
int add(int a, int b);
int divide(int a, int b);
// src/my_lib.cpp
#include "my_lib.h"
int add(int a, int b) {
return a + b;
}
int divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("division by zero");
}
return a / b;
}
现在写测试:
// test/test_my_lib.cpp
#include <gtest/gtest.h>
#include "my_lib.h"
// 基本测试
TEST(MathTest, AddPositive) {
EXPECT_EQ(add(1, 2), 3);
EXPECT_EQ(add(100, 200), 300);
}
TEST(MathTest, AddNegative) {
EXPECT_EQ(add(-1, -2), -3);
EXPECT_EQ(add(-5, 5), 0);
}
// 异常测试
TEST(MathTest, DivideByZero) {
EXPECT_THROW(divide(10, 0), std::invalid_argument);
}
// 死亡测试(程序应该崩溃的场景)
TEST(MathTest, DivideNormal) {
EXPECT_EQ(divide(10, 2), 5);
EXPECT_EQ(divide(7, 3), 2); // 整数除法
}
这里有几个关键点:
TEST()宏的第一个参数是测试套件名,第二个是测试名。你可以把相关的测试放在同一个套件里。EXPECT_EQ是「非致命断言」——失败了还会继续执行后面的代码。还有ASSERT_EQ,一旦失败就终止当前测试。EXPECT_THROW检查是否抛出了指定异常。这个在测试边界条件时特别好用。
运行测试:两种方式
编译完成后,你可以直接运行测试可执行文件:
./build/test_my_lib
输出大概长这样:
[==========] Running 4 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 4 tests from MathTest
[ RUN ] MathTest.AddPositive
[ OK ] MathTest.AddPositive (0 ms)
[ RUN ] MathTest.AddNegative
[ OK ] MathTest.AddNegative (0 ms)
[ RUN ] MathTest.DivideByZero
[ OK ] MathTest.DivideByZero (0 ms)
[ RUN ] MathTest.DivideNormal
[ OK ] MathTest.DivideNormal (0 ms)
[----------] 4 tests from MathTest (0 ms total)
[==========] 4 tests from 1 test suite ran. (0 ms total)
[ PASSED ] 4 tests.
你也可以用 CMake 的 ctest 命令来跑:
cd build
ctest --output-on-failure
ctest 的好处是,如果你的项目里有多个测试可执行文件,它会帮你统一调度。加上 --output-on-failure,只有失败的测试才会打印详细信息,看着清爽。
测试夹具(Test Fixture):复用测试环境
有时候多个测试需要相同的初始化环境。比如测试一个数据库连接类,每个测试前都要打开连接、清空数据。这时候可以用 TEST_F:
class DatabaseTest : public ::testing::Test {
protected:
void SetUp() override {
// 每个测试前执行
db_.connect("localhost");
db_.clear();
}
void TearDown() override {
// 每个测试后执行
db_.disconnect();
}
Database db_;
};
TEST_F(DatabaseTest, InsertRecord) {
db_.insert("key", "value");
EXPECT_EQ(db_.get("key"), "value");
}
TEST_F(DatabaseTest, DeleteRecord) {
db_.insert("key", "value");
db_.deleteKey("key");
EXPECT_THROW(db_.get("key"), std::out_of_range);
}
你看,SetUp 和 TearDown 保证了每个测试都在干净的环境里运行。互不干扰。
SetUp,结果上一个测试留下的脏数据把下一个测试搞挂了。排查了半天才发现。所以,能用 TEST_F 就别手动初始化。
参数化测试:一个测试测多组数据
如果你有一堆输入输出对要验证,一个个写 TEST 太累了。GTest 支持参数化测试:
class AddParamTest : public ::testing::TestWithParam<std::tuple<int, int, int>> {
};
TEST_P(AddParamTest, CheckAdd) {
auto [a, b, expected] = GetParam();
EXPECT_EQ(add(a, b), expected);
}
INSTANTIATE_TEST_SUITE_P(
AddTests,
AddParamTest,
::testing::Values(
std::make_tuple(1, 2, 3),
std::make_tuple(-1, -2, -3),
std::make_tuple(0, 0, 0),
std::make_tuple(100, -50, 50)
)
);
这样,一组数据就是一个测试用例。如果某组数据失败了,GTest 会告诉你具体是哪组。排查起来非常方便。
知识体系总览
我把这一章的核心逻辑画了张图,方便你理解整体脉络:
一些实战建议
最后,分享几个我在项目中踩过的坑和总结的经验:
- 测试要独立——每个测试用例之间不要有依赖。我曾经写过测试 A 往数据库插数据,测试 B 去读。结果跑单测没问题,并行跑就挂了。后来改成每个测试自己建数据。
- 别追求 100% 覆盖率——说实话,有些边界代码(比如错误处理分支)覆盖率很难上去。我一般要求核心逻辑 80% 以上,关键路径 100%。
- 死亡测试别滥用——
EXPECT_DEATH是用来测试「程序应该崩溃」的场景,比如 assert 失败。但如果你能用异常处理代替崩溃,就别用死亡测试。跑起来慢,而且容易误判。 - CI 里一定要跑——我习惯在 GitHub Actions 或 Jenkins 里配置:每次 push 自动编译 + 跑测试。测试不过,代码合不进去。这样团队里谁都不敢偷懒。
公众号:蓝海资料掘金营,微信 deep3321