89、实战:单元测试框架(断言+测试用例注册)

单元测试这东西,说白了就是给自己写的代码上份「保险」。我刚开始做C++项目时,总觉得写测试浪费时间——有那功夫多写几个功能不香吗?直到有一次,我改了一个看似无关紧要的全局变量,结果把三个模块同时搞崩了,调试了整整两天……嗯,从那以后,我再也不敢不写单元测试了。

今天咱们就手撸一个轻量级的单元测试框架。别怕,核心就两个东西:断言测试用例注册

断言:测试的「眼睛」

断言是啥?说白了就是一个判断函数。你给它一个表达式,它告诉你「对了」还是「错了」。我习惯把断言分成几类:

断言类型 作用 典型用法
ASSERT_TRUE 判断条件为真 ASSERT_TRUE(ptr != nullptr)
ASSERT_EQ 判断两个值相等 ASSERT_EQ(result, 42)
ASSERT_NE 判断两个值不等 ASSERT_NE(a, b)
ASSERT_THROW 判断是否抛出异常 ASSERT_THROW(func(), std::runtime_error)

来看一个最简单的断言实现:

#define ASSERT_TRUE(expr) \
    do { \
        if (!(expr)) { \
            std::cerr << "FAIL: " << #expr << " at " << __FILE__ << ":" << __LINE__ << std::endl; \
            return false; \
        } \
    } while(0)

#define ASSERT_EQ(a, b) \
    do { \
        if ((a) != (b)) { \
            std::cerr << "FAIL: " << #a << " == " << #b \
                      << " (actual: " << (a) << " vs " << (b) << ")" \
                      << " at " << __FILE__ << ":" << __LINE__ << std::endl; \
            return false; \
        } \
    } while(0)
小技巧:用 do { ... } while(0) 包裹宏,可以避免 if-else 匹配问题。我早期写宏时吃过这个亏,后来就养成习惯了。

测试用例注册:让框架自动发现测试

有了断言,下一步就是怎么组织测试。你想想看,如果每个测试都要手动调用,那不得累死?我们需要一个注册机制,让测试用例自己「报名」。

我的做法是用一个全局的注册表:

class TestRegistry {
public:
    using TestFunc = std::function<bool()>;
    
    static TestRegistry& instance() {
        static TestRegistry reg;
        return reg;
    }
    
    void registerTest(const std::string& name, TestFunc func) {
        tests_.push_back({name, func});
    }
    
    int runAll() {
        int passed = 0, failed = 0;
        for (auto& [name, func] : tests_) {
            std::cout << "[ RUN  ] " << name << std::endl;
            if (func()) {
                std::cout << "[ PASS ] " << name << std::endl;
                passed++;
            } else {
                std::cout << "[ FAIL ] " << name << std::endl;
                failed++;
            }
        }
        std::cout << "Total: " << passed + failed 
                  << ", Passed: " << passed 
                  << ", Failed: " << failed << std::endl;
        return failed;
    }
    
private:
    struct TestCase {
        std::string name;
        TestFunc func;
    };
    std::vector<TestCase> tests_;
};

#define TEST_CASE(name) \
    static bool test_##name(); \
    namespace { \
        struct Register_##name { \
            Register_##name() { \
                TestRegistry::instance().registerTest(#name, test_##name); \
            } \
        } reg_##name; \
    } \
    static bool test_##name()
核心思路:利用静态变量的构造函数,在程序启动时自动注册测试用例。这样你只需要写 TEST_CASE(MyTest) { ... },剩下的框架帮你搞定。

实战:写一个完整的测试

假设我们要测试一个简单的计算器类:

class Calculator {
public:
    int add(int a, int b) { return a + b; }
    int divide(int a, int b) {
        if (b == 0) throw std::runtime_error("divide by zero");
        return a / b;
    }
};

TEST_CASE(CalculatorAdd) {
    Calculator calc;
    ASSERT_EQ(calc.add(2, 3), 5);
    ASSERT_EQ(calc.add(-1, 1), 0);
    ASSERT_EQ(calc.add(0, 0), 0);
    return true;
}

TEST_CASE(CalculatorDivide) {
    Calculator calc;
    ASSERT_EQ(calc.divide(10, 2), 5);
    ASSERT_EQ(calc.divide(7, 3), 2);  // 整数除法
    return true;
}

TEST_CASE(CalculatorDivideByZero) {
    Calculator calc;
    try {
        calc.divide(1, 0);
        ASSERT_TRUE(false);  // 应该抛出异常
    } catch (const std::runtime_error&) {
        // 预期行为
    }
    return true;
}

int main() {
    return TestRegistry::instance().runAll();
}
注意:测试用例之间应该是相互独立的。我曾经犯过一个错误——在测试A中修改了全局状态,结果测试B莫名其妙地挂了。排查了半天才发现是测试顺序的问题。所以,每个测试用例都要「自包含」,不要依赖其他测试的执行结果。

框架的整体结构

我把整个框架的流程画了张图,方便你理解:

单元测试框架执行流程 TEST_CASE 宏展开 → 静态变量注册 TestRegistry::instance().registerTest() runAll() 遍历所有已注册测试 断言通过 → [PASS] 断言失败 → [FAIL]

进阶:参数化测试

有时候你想用同一套测试逻辑,跑多组数据。我习惯这样写:

#define TEST_CASE_PARAM(name, ...) \
    static bool test_##name(); \
    namespace { \
        struct Register_##name { \
            Register_##name() { \
                TestRegistry::instance().registerTest(#name, test_##name); \
            } \
        } reg_##name; \
    } \
    static bool test_##name()

// 使用示例
bool testAddWithParams() {
    struct { int a, b, expected; } cases[] = {
        {1, 2, 3},
        {0, 0, 0},
        {-1, 1, 0},
        {100, 200, 300}
    };
    Calculator calc;
    for (auto& c : cases) {
        ASSERT_EQ(calc.add(c.a, c.b), c.expected);
    }
    return true;
}
我的习惯:参数化测试的数据量不要太大,5-10组就够了。太多的话,一旦某个数据点失败,你得翻半天日志才能找到是哪个参数出了问题。

避坑指南

写单元测试框架时,有几个坑我踩过,分享给你:

  • 宏展开的副作用——ASSERT_EQ(a++, b) 这种写法,宏展开后 a++ 会被执行两次。所以断言参数里别放带副作用的表达式。
  • 静态初始化顺序——不同编译单元中的静态变量初始化顺序是不确定的。如果你的测试用例依赖了其他全局状态,可能会出问题。我一般把测试注册和测试逻辑分开,注册只存函数指针,不执行任何逻辑。
  • 线程安全——如果你的测试框架要在多线程环境下用,记得给注册表加锁。不过大多数场景下,单线程就够了。

好了,这个轻量级的单元测试框架就完成了。别看它简单,我在好几个项目里都用过这套思路,稳定可靠。你完全可以在此基础上扩展——比如加个测试分组、输出XML报告、或者集成到CI流水线里。

记住,写测试不是为了应付差事,而是为了让你晚上能睡个安稳觉。代码改了,跑一遍测试,绿了,心里就踏实了。