60、Google Test:集成GTest,编写和运行单元测试

单元测试这件事,说实话,我入行前三年基本没当回事。总觉得「代码能跑就行,测什么测」。直到有一次,我改了一个工具函数,自以为万无一失,结果上线后把整个系统的配置解析全搞崩了。嗯,从那以后,我再也不敢不写单元测试了。

今天咱们聊的就是 C++ 社区里最主流的测试框架——Google Test,简称 GTest。怎么把它集成到 CMake 项目里,怎么写测试用例,怎么跑起来。我保证,看完这一章,你也能给自己的项目加上「安全网」。

为什么是 GTest?

市面上 C++ 测试框架不少,Catch2、Boost.Test、 doctest 都有。但我个人习惯用 GTest,原因很简单:

  • Google 出品,社区活跃——遇到问题 StackOverflow 上一搜一大把
  • 断言丰富——从基本比较到异常检查,基本覆盖了所有场景
  • 与 CMake 集成丝滑——官方提供了 FetchContent 和 find_package 两种方式
  • 支持死亡测试——可以测试「程序应该崩溃」的场景,这个很实用
小提示:如果你在做一个跨平台的开源项目,GTest 几乎是标配。Chromium、LLVM、TensorFlow 都在用。

集成方式:两种主流方案

把 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

注意:FetchContent 第一次运行会下载源码,如果网络不好可能会卡住。我建议你提前把 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 检查是否抛出了指定异常。这个在测试边界条件时特别好用。
我的习惯:能用 EXPECT_ 就别用 ASSERT_。除非后面的代码依赖前面的结果,否则让测试跑完,你能看到所有失败点,而不是修一个发现一个。

运行测试:两种方式

编译完成后,你可以直接运行测试可执行文件:

./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);
}

你看,SetUpTearDown 保证了每个测试都在干净的环境里运行。互不干扰。

避坑指南:我曾经在测试里忘了调用 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 会告诉你具体是哪组。排查起来非常方便。

知识体系总览

我把这一章的核心逻辑画了张图,方便你理解整体脉络:

GTest 集成与使用知识体系 集成方式 • FetchContent(推荐) 自动下载源码 • find_package 系统安装后查找 测试编写 • TEST() 基本测试 • TEST_F() 测试夹具 • TEST_P() 参数化测试 • EXPECT_ vs ASSERT_ 运行方式 • 直接运行可执行文件 • ctest 统一调度 • --output-on-failure 最佳实践与避坑指南 • 每个测试独立,不依赖执行顺序 • 测试覆盖率不是越高越好,关键路径必须覆盖 • 死亡测试只用于「预期内的崩溃」,别滥用 • CI 中集成测试,提交代码自动跑

一些实战建议

最后,分享几个我在项目中踩过的坑和总结的经验:

  • 测试要独立——每个测试用例之间不要有依赖。我曾经写过测试 A 往数据库插数据,测试 B 去读。结果跑单测没问题,并行跑就挂了。后来改成每个测试自己建数据。
  • 别追求 100% 覆盖率——说实话,有些边界代码(比如错误处理分支)覆盖率很难上去。我一般要求核心逻辑 80% 以上,关键路径 100%。
  • 死亡测试别滥用——EXPECT_DEATH 是用来测试「程序应该崩溃」的场景,比如 assert 失败。但如果你能用异常处理代替崩溃,就别用死亡测试。跑起来慢,而且容易误判。
  • CI 里一定要跑——我习惯在 GitHub Actions 或 Jenkins 里配置:每次 push 自动编译 + 跑测试。测试不过,代码合不进去。这样团队里谁都不敢偷懒。
一句话总结:GTest + CMake 是 C++ 项目测试的黄金组合。花半天时间搭好框架,后面能省你无数个通宵排查的夜晚。

公众号:蓝海资料掘金营,微信 deep3321