CMake 和 GTest 结合

准备工作

参考链接:研发基本功 - GTest / GMock 单元测试实践手册

CMake 找到 GTest

先编译安装 GTest,下面是示例 CMakeLists.txt 内容如下:

1
2
3
4
5
find_package(GTest REQUIRED)		// 找到GTest
include_directories(${GTEST_INCLUDE_DIRS})
enable_testing() // 启用测试支持
add_subdirectory(src) // 源码
add_subdirectory(test) // 添加 test 子目录,这下面就可以用 GTest 语法写测试函数

如何把写好的测试程序进行测试

我们已经在 test 文件夹下 SplitChineseTest.cpp 中写好一个测试函数:

1
2
3
4
5
6
TEST(SplitChineseRmStopWordsTests, HandlesEmptyString) {
SplitChinese splitter("sources/project/stop/stop_words_zh.txt", "conf/jieba.json");
std::string input = "";
std::string result = splitter.rmStopWords(input);
EXPECT_EQ(result, "");
}

然后在 CMakeLists.txt 文件中注册:

1
2
3
4
5
6
7
# 为 SplitChineseTest 创建测试
add_executable(SplitChineseTest SplitChineseTest.cpp)
target_link_libraries(SplitChineseTest PRIVATE gtest_main gtest pthread)

# 注册测试
include(GoogleTest)
gtest_discover_tests(SplitChineseTest)

如果测试的这个函数依赖相关的头文件和源文件一定要包含进来。

因此,一般我的文件结构是这样的:

1
2
3
4
5
6
7
8
test/
├── CMakeLists.txt ①
├── SplitChineseTest
│   ├── CMakeLists.txt ②
│   └── SplitChineseTest.cpp
└── SplitEnglishTest
├── CMakeLists.txt ③
└── SplitEnglishTest.cpp

如果我不需要再对某个 cpp 文件进行测试,只需要在 ① 中把包含的 ② 或 ③ 取消掉即可。

1
2
#add_subdirectory(SplitChineseTest)	// 不再测试
add_subdirectory(SplitEnglishTest) // 依旧进行测试

关键注意事项:

  1. enable_testing() 是必须的,否则无法注册和运行测试。
  2. 如果项目有多个子目录,每个目录的 CMakeLists.txt 文件要正确传递依赖关系。
  3. 推荐使用 gtest_discover_tests(),它会自动发现并注册所有测试用例。

这样,就可以在 CMake 项目中方便地集成和运行 GTest 了。

遇到的问题

我们会测试类的成员函数,因此要把本该私有的成员函数单独放一起,测试的时候就临时设置为 public,如果还需要用到某个成员变量,也是要这么做。

尽管 Gtest 有设置友元等方法,但是这污染了我们的代码,个人并不喜欢这样,如果你乐意也可以尝试,毕竟重构代码的时候随时可以测试。下面演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <gtest/gtest.h>

class MyClass {
private:
int secret_value = 42;

int secret_function() const {
return secret_value * 2;
}

public:
int public_function() const {
return secret_function() + 1;
}

// 使用 FRIEND_TEST 宏
FRIEND_TEST(MyClassTest, AccessPrivateMembers);
};

下面是我们测试的程序中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include <gtest/gtest.h>
#include "MyClass.h"

TEST(MyClassTest, AccessPrivateMembers) {
MyClass my_class;

// 直接访问私有成员
EXPECT_EQ(my_class.secret_value, 42);

// 调用私有函数
EXPECT_EQ(my_class.secret_function(), 84);
}

语法

基本概念:Test Suite 和 Test Case

Test Suite

1
2
3
4
TEST(TestSuiteName, TestCaseName) {
// 单测代码(往往是做一些准备工作,然后在最后调用断言)
EXPECT_EQ(func(0), 0);
}

一个文件里只能有一个 TestSuiteName,建议命名为这个文件测试的类名。

TestCaseName 是测试用例的名称。命名要有意义,能够从名称上判断行为,如果实在不可以再进行简单的注释。

TestSuiteName 和 TestCaseName 组合要唯一

GTest 生成的类名是带下划线的,所以上面这些名字里不建议有下划线

1
2
3
4
5
6
7
8
9
10
11
12
13
TEST(SplitChinese, HandlesEmptyString) {
SplitChinese splitter("sources/project/stop/stop_words_zh.txt", "conf/jieba.json");
std::string input = "";
std::string result = splitter.rmStopWords(input);
EXPECT_EQ(result, "");
}

TEST(SplitChinese, HandlesStringWithNoStopWords) {
SplitChinese splitter("sources/project/stop/stop_words_zh.txt", "conf/jieba.json");
std::string input = "这是一个测试";
std::string result = splitter.rmStopWords(input);
EXPECT_EQ(result, "这是 一个 测试");
}

从 HandlesEmptyString 名称就看出是测试空字符,HandlesStringWithNoStopWords 是测试没有停用词的情况。

其他规则同样符合前面所讲。

Test Case

我们会有这样一种场景,就是后面程序要用的变量是一致的 ,为了避免重复写初始化变量的代码,可以用这种方式进行简化,简单来说就是利用作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
TEST(Foo, bar) {
Context ctx;

{ // case 1
params.enable_refresh = true;
ASSERT_EQ(ctx->is_enable_fresh(), true);
}

{ // case 2
params.enable_refresh = false;
ASSERT_EQ(ctx->is_enable_fresh(), false);
}
}

还可以利用这点避免写多个测试函数,可以全部放在一个函数中,只不过这样测试的含义就不够明确,你也许可以考虑在旁边写注释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TEST(Foo, bar) {

{ // case 1
Context ctx;
params.enable_refresh = true;
ASSERT_EQ(ctx->is_enable_fresh(), true);
}

{ // case 2
Context ctx;
params.enable_refresh = false;
ASSERT_EQ(ctx->is_enable_fresh(), false);
}
}

善用 TEST_F,避免写重复的代码

前面讲的 TEST 是基本的测试宏,代表一个最小测试单元。在执行 TEST 宏时,GTest 会为每个 TEST 定义一个独立的实例,使其互相隔离,避免对同一个变量进行修改或共享等可能带来的副作用。

下面我们介绍 TEST_F,可以在多个测试用例之间共享数据结构或方法。对于同一个 Test Suite 的所有 Test Cases,会创建一个 TestFixture 对象,其 SetUp 函数会在每个 Test Case 执行之前被调用,而 TearDown 函数则会在每个 Test Case 执行之后被调用。

  • 将共享的变量作为成员变量,可以在 Test Case 中直接访问;变量初始化、回收逻辑放到 SetUp()、TearDown()。
  • 提供公共方法,可以在 Test Case 中直接使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class FooTest : public ::testing::Test {
protected:
// 在每个 Test Case 运行开始前,都会调用 SetUp,这里可以初始化
void SetUp() override {
ctx = RequestContext("123");
}

// 在每个 Test Case 运行结束后,都会调用 TearDown
void TearDown() override {}

// 所有 Test Case 都可以直接访问这些变量和方法
Ad new_ad() { return Ad(ctx); }
RequestContext ctx;
};

TEST_F(FooTest, enable_foo) { // 这里会初始化 FooTest 对象
ctx->params.enable_foo = true; // 可以访问 FooTest 中的变量
auto item = new_ad(); // 可以调用 FooTest 中的方法
...
}

// 每个 Test Case 都是独立的,这里会初始化另一个 FooTest 对象
TEST_F(FooTest, OnTestProgramStart) {
// ...
}

断言:EXPECT 与 ASSERT 宏

如果某个判断不通过时,会影响后续步骤,要使用 ASSERT。常见的是空指针,或者数组访问越界。

其他情况,可以使用 EXPECT,尽可能多测试几个用例。

EXPECT 断言失败会继续往下执行,但是 ASSERT 断言失败会终止。

很多方式可见:http://google.github.io/googletest/reference/assertions.html

下面介绍常用的 EXPECT 断言,ASSERT 只需要进行替换即可:

基本断言

  • EXPECT_TRUE(condition) // 条件为 true 时断言成功
  • EXPECT_FALSE(condition) // 条件为 false 时断言成功

比较断言

  • EXPECT_EQ(val1, val2) // 检查两值是否相等
  • EXPECT_NE(val1, val2) // 检查两值是否不相等
  • EXPECT_LT(val1, val2) // 检查 val1 < val2
  • EXPECT_LE(val1, val2) // 检查 val1 <= val2
  • EXPECT_GT(val1, val2) // 检查 val1 > val2
  • EXPECT_GE(val1, val2) // 检查 val1 >= val2

字符串断言

  • EXPECT_STREQ(str1, str2) // 检查 C 字符串是否相等
  • EXPECT_STRNE(str1, str2) // 检查 C 字符串是否不相等
  • EXPECT_STRCASEEQ(str1, str2) // 检查 C 字符串(忽略大小写)是否相等
  • EXPECT_STRCASENE(str1, str2) // 检查 C 字符串(忽略大小写)是否不相等

浮点数断言

  • EXPECT_FLOAT_EQ(val1, val2) // 检查两个 float 是否近似相等
  • EXPECT_DOUBLE_EQ(val1, val2) // 检查两个 double 是否近似相等
  • EXPECT_NEAR(val1, val2, abs_error) // 检查两个浮点数是否在误差范围内

异常断言

  • EXPECT_THROW(statement, type) // 检查代码是否抛出了指定类型的异常
  • EXPECT_ANY_THROW(statement) // 检查代码是否抛出了任意异常
  • EXPECT_NO_THROW(statement) // 检查代码是否未抛出任何异常

补充说明

自定义消息

所有断言支持自定义输出消息,形式为:

1
EXPECT_EQ(x, y) << "x 和 y 不相等,x=" << x << ", y=" << y;

复合断言

将多个断言组合在一个测试用例中。例如:

1
2
3
4
5
TEST(SampleTest, CombinedAssertions) {
ASSERT_TRUE(5 > 3);
EXPECT_EQ("hello", std::string("hello"));
ASSERT_NEAR(0.1, 0.1001, 0.001);
}

技巧

编译参数

访问私有变量:-fno-access-control,放在单测的 optimize 参数里。

修改 const 字段:使用 const_cast<Type&> 修改常量类型。

优化级别改为 O0:单测覆盖率报告更准。

运行单侧

运行特定单测

1
--gtest_filter=[TestSuiteName.TestName]

示例:

1
2
3
4
5
6
7
8
9
10
11
// 运行特定测试方法
./test --gtest_filter=MyTestSuite.MyTest

// 运行特定测试套件下的所有测试
./test --gtest_filter=MyTestSuite.*

// 运行多个指定的测试(用 : 分隔)
./test --gtest_filter=TestSuite1.Test1:TestSuite2.Test2

// 排除测试(用 -)
./test --gtest_filter=-TestSuite1.Test1

重复运行单测多次

有些单元测试涉及到多线程,可能会偶发性的不通过。

1
--gtest_repeat=[repeat_count]

运行测试 10 次:

1
2
3
// 运行测试 10 次

./test --gtest_repeat=10
1
2
3
// 运行测试 10 次

./test --gtest_repeat=10 --gtest_break_on_failure
1
2
3
// 	无限重复(可以用 Ctrl+C 停止)

./test --gtest_repeat=-1

临时禁用某个单测

可以使用DISABLED_前缀来跳过某项测试:

1
2
3
4
5
// 所有属于 BarTest 测试套件的测试都会被禁用
TEST_F(DISABLED_BarTest, DoesXyz) { ... }

// 仅禁用了测试套件 BarTest 中名为 DoesXyz 的单个测试,其他测试仍然会被正常运行
TEST_F(BarTest, DISABLED_DoesXyz) { ... }

单测书写规范

目录结构、文件与命名规范

单测的目录结构,要和源码的目录结构一致

单测文件的路径名,等价于源码的文件名加上 _test 后缀。

目的在于:让写单测的人能很快定位是否已经有这个文件或这个类的单测,让新增代码更聚合,避免写重复单测。

1
2
3
4
5
6
7
8
9
10
src/
common/
item_data.cpp
frame/
request_context.cpp
unittest/
common/
item_data_test.cpp
frame/
request_context_test.cpp

TestSuite 和 TestCase 命名规则

TestSuite 建议命名为被测试的类名加上 Test 后缀:

1
2
// good
TEST(RequestContextTest, foo) {...}

TestCase 建议命名为被测试的函数名,不要随意起名,也不需要增加不必要的前缀:

1
2
3
4
// good
TEST(RequestContextTest, init_uav_to_group_bid) { // 不需要加 test_ 前缀
ASSERT_EQ(ctx->init_uav_to_group_bid(), 1);
}

GTest 生成的类名是带下划线的,所以上面这些名字建议用驼峰形式。

写优雅的、可理解的、易于维护的单测:代码风格与注释

不要用 std::cout 输出变量值,改为用 ASSERT / EXPECT 检查

能用 EXPECT 就不要写 std::cout:

  • 如果 cout 的日志是确定性的,那么应该写成断言。
  • 如果是 debug 用的,那么在写完单测后应该删除。
  • 如果期望单测失败时打印,那么应该放在 EXPECT_CALL()... << ... 后面,而不是直接输出。
  • 除此之外,这些日志没有任何意义,只会刷屏,没有保留的必要。
1
2
EXPECT_EQ(rsp.size(), 1); // 这一行在检测失败时,会打印 rsp.size() 的值
EXPECT_EQ(rsp.size(), 1) << rsp.ads.debug_string() << std::endl; // 可以在检测失败时,打印更多 debug 日志

不要直接写数值,要写清楚这个数字是怎么算的

直接写一个数字 2965,其他人并不知道这个数字是怎么算出来的,后续有问题也不好排查。

写出这个数字的计算过程,映射到代码分支上,其他人好看懂。这也是白盒化单测的表现之一。

1
2
3
4
5
6
7
// good
params.alpha = 2;
params.beta = 2.5;
ASSERT_EQ(params.get_score(), 2 * 2.5 * 593); // alpha * beta * ctx.bid

// good: 把变量名直接注释在字面量后面
ASSERT_EQ(params.get_score(), 2 /* alpha */ * 2.5 /* beta */ * 593 /* ctx.bid */);

为单测补充详细的注释

单测写出来必须的白盒的、可理解的、可维护的。如果不补充注释,其他人根本看不懂这些单测在测试什么逻辑,也无法确保其有效,后续修单测也很痛苦。

为单测补充注释时,重点要说明「这些赋值对应了哪个分支条件」,目标是让其他人扫一眼源码就能知道这些单测在测试哪些逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// bad
req.type = Type::foo;
req.from = "localhost";
EXPECT_EQ(ctx.get_value(), 5);

// good:补充注释
req.type = Type::foo; // is_foo()
req.from = "localhost"; // is_local_req()
EXPECT_EQ(ctx.get_value(), 5); // 本地请求,默认值是 5

// best:代码即注释
req.type = Type::foo;
ASSERT_TRUE(ctx->is_foo());
req.from = "localhost";
ASSERT_TRUE(ctx->is_local_req());
EXPECT_EQ(ctx.get_value(), 5); // 本地请求,默认值是 5

写稳定的单测

单测里禁止访问外部服务,最好是整个单测能够断网。