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莫名其妙地挂了。排查了半天才发现是测试顺序的问题。所以,每个测试用例都要「自包含」,不要依赖其他测试的执行结果。
框架的整体结构
我把整个框架的流程画了张图,方便你理解:
进阶:参数化测试
有时候你想用同一套测试逻辑,跑多组数据。我习惯这样写:
#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流水线里。
记住,写测试不是为了应付差事,而是为了让你晚上能睡个安稳觉。代码改了,跑一遍测试,绿了,心里就踏实了。