1. 为什么我们需要gtest+stub组合?
在C语言开发中,单元测试常常会遇到一个棘手的问题:如何测试那些依赖外部函数的代码?比如你写了一个处理数据的函数,但这个函数内部调用了文件读写操作。在单元测试时,我们显然不希望真的去操作文件系统。传统的做法是使用gmock,但它有个致命缺陷——只能mock虚函数。
我遇到过这样一个真实案例:一个硬件驱动模块需要测试,但其中调用了大量直接操作寄存器的函数。这些函数既不是虚函数,也不是类成员函数,gmock完全无能为力。这时候stub工具就像救世主一样出现了——它通过直接替换函数地址的方式,完美解决了非虚函数的打桩问题。
2. gtest+stub环境搭建指南
2.1 基础环境准备
首先确保你已经安装了gtest。如果还没安装,可以用以下命令快速安装:
sudo apt-get install libgtest-dev cd /usr/src/gtest sudo cmake . sudo make sudo cp *.a /usr/lib接下来获取cpp-stub工具,这个步骤简单到令人发指:
git clone https://github.com/coolxv/cpp-stub.git cp cpp-stub/src/stub.h /usr/local/include/没错,就这么简单!我当初第一次用时都不敢相信,整个安装过程不到3分钟。相比gmock那复杂的安装配置流程,stub简直太友好了。
2.2 项目配置要点
在你的CMakeLists.txt中需要添加这些配置:
find_package(GTest REQUIRED) include_directories(${GTEST_INCLUDE_DIRS}) add_executable(YourTest test.cpp) target_link_libraries(YourTest ${GTEST_LIBRARIES} pthread)特别注意:测试用例文件需要包含被测的.c文件,而不是.h文件。这是很多新手容易踩的坑。我当初就因为这个浪费了半天时间排查链接错误。
3. 实战:给文件操作函数打桩
3.1 被测代码分析
假设我们有这样一个文件操作模块file_util.c:
#include "file_util.h" #include <stdio.h> int check_file_exists(const char* path) { FILE* file = fopen(path, "r"); if (file) { fclose(file); return 1; } return 0; } int process_file(const char* path) { if (!check_file_exists(path)) { return -1; // 文件不存在 } // 其他处理逻辑 return 0; }3.2 编写测试桩
我们要测试process_file函数,但不想真的创建文件。这时就可以对check_file_exists打桩:
#include <gtest/gtest.h> #include "stub.h" #include "../src/file_util.c" // 注意是.c文件 // 桩函数1:总是返回文件存在 int stub_file_exists_true(const char*) { return 1; } // 桩函数2:总是返回文件不存在 int stub_file_exists_false(const char*) { return 0; } TEST(FileTest, ProcessExistingFile) { Stub stub; stub.set(check_file_exists, stub_file_exists_true); int ret = process_file("any_path"); EXPECT_EQ(ret, 0); } TEST(FileTest, ProcessNonExistingFile) { Stub stub; stub.set(check_file_exists, stub_file_exists_false); int ret = process_file("any_path"); EXPECT_EQ(ret, -1); }3.3 测试结果分析
运行测试后,你会看到类似这样的输出:
[==========] Running 2 tests from 1 test case. [----------] 2 tests from FileTest [ RUN ] FileTest.ProcessExistingFile [ OK ] FileTest.ProcessExistingFile (0 ms) [ RUN ] FileTest.ProcessNonExistingFile [ OK ] FileTest.ProcessNonExistingFile (0 ms)这个案例展示了如何模拟文件存在与否的不同场景。在实际项目中,我用这种方法测试过各种边界条件,比如磁盘满、权限不足等情况,只需要编写不同的桩函数即可。
4. 高级技巧与避坑指南
4.1 多层级打桩策略
有时候我们需要对同一个函数在不同测试用例中打不同的桩。这时候要注意作用域问题:
TEST(ScopeTest, MultiStub) { { // 第一个作用域 Stub stub1; stub1.set(func, stub_func1); // 这里func会被替换为stub_func1 } // stub1析构,func恢复原状 { // 第二个作用域 Stub stub2; stub2.set(func, stub_func2); // 这里func会被替换为stub_func2 } }这个技巧在测试状态机时特别有用。我曾经用这种方法测试过一个网络协议栈,对不同状态下的回调函数打不同的桩。
4.2 常见问题排查
段错误:通常是因为桩函数签名与原函数不一致。一定要确保参数列表和返回类型完全匹配。
链接错误:最常见的原因是包含了.h文件而不是.c文件。记住stub需要替换实际函数,所以必须能访问到函数定义。
桩函数不生效:检查是否在调用被测函数前设置了桩。我建议在TEST宏开始就设置好桩。
静态函数问题:对于static函数,stub确实无能为力。这种情况要么修改代码设计,要么考虑使用链接时替换的技巧。
5. 真实项目案例分享
去年我在开发一个嵌入式项目时,遇到了硬件抽象层(HAL)的测试难题。HAL中有很多这样的函数:
uint32_t read_register(uint32_t addr) { return *(volatile uint32_t*)addr; }使用stub后,测试变得非常简单:
uint32_t stub_read_register(uint32_t) { return 0x1234ABCD; // 模拟寄存器值 } TEST(HALTest, RegisterRead) { Stub stub; stub.set(read_register, stub_read_register); uint32_t val = some_function_using_register(); EXPECT_EQ(val, 0x1234ABCD); }通过这种方式,我们实现了:
- 无需真实硬件即可测试
- 可以模拟各种寄存器值组合
- 测试速度极快,无需硬件初始化
项目最终单元测试覆盖率达到了90%以上,这在嵌入式领域是非常难得的成绩。
6. 与其他工具的对比
6.1 stub vs gmock
| 特性 | stub | gmock |
|---|---|---|
| 适用语言 | C/C++ | C++ |
| 需要虚函数 | 否 | 是 |
| 代码量 | 少 | 多 |
| 学习曲线 | 平缓 | 陡峭 |
| 性能影响 | 极小 | 中等 |
6.2 stub vs 函数指针替换
有些人喜欢用函数指针来达到类似效果,比如:
// 原函数 int func() { return 1; } // 测试代码 int (*orig_func)() = func; int stub_func() { return 2; } // 替换 func = stub_func;这种方法有几个缺点:
- 需要修改生产代码
- 不是线程安全的
- 恢复起来比较麻烦
相比之下,stub工具完美解决了这些问题,而且使用更加简单安全。
7. 性能考量与最佳实践
经过实测,stub带来的性能开销几乎可以忽略不计。在我的i7处理器上测试,100万次函数调用:
- 直接调用:约3ms
- stub替换后调用:约5ms
- gmock替换后调用:约15ms
对于单元测试来说,这点开销完全可以接受。不过还是有一些优化建议:
避免频繁设置/恢复桩:尽量在测试用例开始时就设置好所有需要的桩。
重用桩函数:对于简单的返回值模拟,可以编写通用桩函数。
注意测试顺序:有些测试可能会依赖全局状态,合理安排测试顺序可以减少桩的切换次数。
我在一个大型项目中使用这些技巧,将测试套件运行时间从15分钟缩短到了8分钟,效果非常显著。