二十三、自定义测试框架:编写测试运行器、注册测试用例、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();
}
避坑指南:我曾经忘记在运行器里捕获异常,结果一个测试挂了,整个进程直接崩溃,后面的测试全没跑。所以一定要用try-catch包住每个测试的执行。

运行效果大概是这样:

[PASS] addition
[PASS] subtraction
[FAIL] division - integer division by zero

Total: 3 | Passed: 2 | Failed: 1

CMake集成自定义测试

框架写好了,怎么让CMake认识它?关键是用add_testenable_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”的测试。

整体架构图

下面这张图展示了自定义测试框架的核心流程:

自定义测试框架核心流程 TEST_CASE宏 定义测试函数 全局变量构造函数 自动注册到TestRegistry TestRegistry 存储所有测试用例 TestRunner 遍历注册表,逐个执行并捕获异常 结果报告 [PASS]/[FAIL] + 统计 CMake集成 add_test + enable_testing CTest 收集并展示结果

总结

自定义测试框架其实不复杂。核心就三点:

  • 注册机制:利用全局变量和静态初始化,让测试用例自动注册。
  • 运行器:遍历注册表,逐个执行,异常隔离。
  • CMake集成:用add_test把运行器注册为CTest测试。

我后来在好几个嵌入式项目里都用了这套方案,轻量、无依赖、可控。当然,如果你项目允许,直接用Google Test更省事。但理解了这个原理,你就能在需要的时候自己造一个趁手的工具。

注意:全局变量的初始化顺序在C++中是不确定的。如果你的测试用例依赖其他全局状态,可能会出问题。我建议测试函数里不要依赖任何全局变量的初始化顺序,保持独立。

好了,这一章就到这里。代码都在上面,你可以直接复制到项目里试试。有什么问题,欢迎交流。


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