二十三、自定义测试框架:编写测试运行器、注册测试用例、CMake集成自定义测试
说实话,做C++项目久了,你总会遇到一个尴尬的局面——项目里的测试框架不够用,或者干脆没有。我早年接手过一个遗留系统,单元测试全靠一堆main函数手动跑,谁改代码谁自己点一遍。那滋味,真不好受。
所以这一章,咱们聊聊怎么自己写一个轻量级的测试框架。别一听“框架”就觉得高大上,说白了就是三件事:怎么注册测试、怎么运行测试、怎么集成到CMake里。我保证,你跟着走一遍,以后项目里再也不用羡慕别人家的Google Test了。
为什么需要自定义测试框架?
你可能会问:“有现成的Catch2、Google Test不用,自己造轮子干嘛?”
嗯,我遇到过几种情况:
- 嵌入式环境:目标平台没有标准库,或者内存极小,跑不了完整的测试框架。
- 公司政策:某些项目不允许引入第三方库,所有代码必须自研。
- 学习目的:理解测试框架的原理,能帮你更好地使用现成框架。
说白了,自己写一个,你才知道那些宏、那些注册机制背后到底干了什么。
核心设计:测试注册器
我们先想一个问题:怎么让测试用例“自动”被框架发现?
最简单的办法——全局注册。每个测试用例在定义时,通过一个全局变量把自己注册到一个列表中。我早期做的一个小框架就是这么干的。
来看代码:
// test_registry.h
#pragma once
#include <functional>
#include <string>
#include <vector>
struct TestCase {
std::string name;
std::function<void()> func;
};
class TestRegistry {
public:
static TestRegistry& instance() {
static TestRegistry reg;
return reg;
}
void registerTest(const std::string& name, std::function<void()> func) {
tests_.push_back({name, std::move(func)});
}
const std::vector<TestCase>& allTests() const { return tests_; }
private:
std::vector<TestCase> tests_;
};
// 辅助宏:自动注册
#define TEST_CASE(name) \
static void test_##name(); \
namespace { \
struct Register_##name { \
Register_##name() { \
TestRegistry::instance().registerTest(#name, test_##name); \
} \
} reg_##name; \
} \
static void test_##name()
使用起来很简单:
// test_math.cpp
#include "test_registry.h"
#include <cassert>
TEST_CASE(addition) {
assert(1 + 1 == 2);
}
TEST_CASE(subtraction) {
assert(3 - 1 == 2);
}
你看,每个测试用例定义时,全局变量reg_xxx的构造函数就会把函数指针注册到TestRegistry里。这就是“自动注册”的本质。
编写测试运行器
有了注册器,下一步就是运行器。运行器负责遍历所有已注册的测试,逐个执行,并报告结果。
// test_runner.cpp
#include "test_registry.h"
#include <iostream>
#include <string>
int runAllTests() {
int passed = 0;
int failed = 0;
for (const auto& test : TestRegistry::instance().allTests()) {
try {
test.func();
std::cout << "[PASS] " << test.name << std::endl;
++passed;
} catch (const std::exception& e) {
std::cout << "[FAIL] " << test.name
<< " - " << e.what() << std::endl;
++failed;
} catch (...) {
std::cout << "[FAIL] " << test.name
<< " - unknown exception" << std::endl;
++failed;
}
}
std::cout << "\nTotal: " << (passed + failed)
<< " | Passed: " << passed
<< " | Failed: " << failed << std::endl;
return failed > 0 ? 1 : 0;
}
int main() {
return runAllTests();
}
运行效果大概是这样:
[PASS] addition
[PASS] subtraction
[FAIL] division - integer division by zero
Total: 3 | Passed: 2 | Failed: 1
CMake集成自定义测试
框架写好了,怎么让CMake认识它?关键是用add_test和enable_testing。
我一般这样组织项目结构:
project/
├── CMakeLists.txt
├── src/
│ └── math.cpp
├── tests/
│ ├── CMakeLists.txt
│ ├── test_registry.h
│ ├── test_runner.cpp
│ ├── test_math.cpp
│ └── test_other.cpp
根目录的CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(MyProject)
add_subdirectory(src)
add_subdirectory(tests)
tests目录的CMakeLists.txt:
enable_testing()
# 编译测试运行器 + 所有测试源文件
add_executable(test_runner
test_runner.cpp
test_math.cpp
test_other.cpp
)
target_include_directories(test_runner PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
# 注册为CTest测试
add_test(NAME all_tests COMMAND test_runner)
file(GLOB ...)收集所有test_*.cpp,这样新增测试文件时不用改CMakeLists.txt。但注意,GLOB在CMake里不会自动检测新文件,需要重新cmake。
然后运行:
mkdir build && cd build
cmake ..
cmake --build .
ctest --output-on-failure
你会看到CTest输出:
Test project /path/to/build
Start 1: all_tests
1/1 Test #1: all_tests ....................... Passed 0.01 sec
100% tests passed, 0 tests failed out of 1
进阶:支持测试过滤和标签
项目大了以后,你可能只想跑某几个测试。我后来给框架加了个简单的过滤功能:
// 在TestRegistry里加一个过滤方法
std::vector<TestCase> filterTests(const std::string& pattern) const {
std::vector<TestCase> result;
for (const auto& t : tests_) {
if (t.name.find(pattern) != std::string::npos) {
result.push_back(t);
}
}
return result;
}
然后在运行器里支持命令行参数:
int main(int argc, char* argv[]) {
std::string filter = (argc > 1) ? argv[1] : "";
auto tests = filter.empty()
? TestRegistry::instance().allTests()
: TestRegistry::instance().filterTests(filter);
// ... 执行tests ...
}
CMake里可以这样传参:
add_test(NAME math_tests COMMAND test_runner "math")
add_test(NAME all_tests COMMAND test_runner)
这样,ctest -R math就能只跑名字包含“math”的测试。
整体架构图
下面这张图展示了自定义测试框架的核心流程:
总结
自定义测试框架其实不复杂。核心就三点:
- 注册机制:利用全局变量和静态初始化,让测试用例自动注册。
- 运行器:遍历注册表,逐个执行,异常隔离。
- CMake集成:用
add_test把运行器注册为CTest测试。
我后来在好几个嵌入式项目里都用了这套方案,轻量、无依赖、可控。当然,如果你项目允许,直接用Google Test更省事。但理解了这个原理,你就能在需要的时候自己造一个趁手的工具。
好了,这一章就到这里。代码都在上面,你可以直接复制到项目里试试。有什么问题,欢迎交流。