9、TDD实战-计算器:从零开始用TDD开发一个支持加减乘除的计算器类
好了,咱们终于到了动手环节。前面讲了那么多TDD的理论、红绿重构循环、测试替身什么的,说实话,不亲手写一遍,那些东西始终是别人的经验。这一章,我们就用最经典的入门项目——计算器,来完整走一遍TDD流程。
我个人习惯,每次开始一个新功能前,先问自己三个问题:这个类要做什么?它的接口长什么样?第一个最简单的测试用例是什么?想清楚了再动键盘。
9.1 先写测试,再写代码——加法起步
按照TDD的铁律,测试必须先于实现代码。我们第一个测试,就从最简单的加法开始。
// test_calculator.cpp
#include <gtest/gtest.h>
#include "calculator.h"
TEST(CalculatorTest, AddTwoPositiveNumbers) {
Calculator calc;
int result = calc.add(3, 5);
EXPECT_EQ(result, 8);
}
写这个测试的时候,我脑子里其实已经在想Calculator类的接口了。但注意,这时候我还没写calculator.h。测试编译当然会失败——这就是TDD里的"红色"阶段。
关键点:测试失败不是因为逻辑不对,而是因为代码还不存在。这是正常的,也是TDD刻意为之的节奏。
好,现在让测试通过。写最少的代码:
// calculator.h
#pragma once
class Calculator {
public:
int add(int a, int b) {
return a + b;
}
};
编译,运行测试——绿色通过。嗯,第一个循环走完了。
9.2 减法:边界情况要提前想
加法的测试通过后,我们加减法。但这里有个坑——减法结果可能为负数。我刚开始做TDD时,就吃过这个亏,只测了正数减正数,结果上线后用户输入负数就崩了。
TEST(CalculatorTest, SubtractResultNegative) {
Calculator calc;
int result = calc.subtract(3, 8);
EXPECT_EQ(result, -5);
}
TEST(CalculatorTest, SubtractTwoNegatives) {
Calculator calc;
int result = calc.subtract(-5, -3);
EXPECT_EQ(result, -2);
}
你看,我写了两个测试用例:一个结果负,一个两个操作数都负。这样实现的时候,就不会漏掉边界。
实现代码:
int subtract(int a, int b) {
return a - b;
}
简单吧?但正是这种"简单到你觉得没必要测"的东西,往往最容易出问题。我曾经在一个金融项目里,就因为减法没考虑精度,导致对账差了0.01元,查了整整两天。
9.3 乘法与除法:引入浮点数
加减法都是整数运算,但除法不一样。3除以2,结果是1.5。所以我们需要引入浮点数。
这里有个设计决策:是让计算器类支持模板,还是直接返回double?我个人建议,初期先固定返回double,等后面有需求再重构。别过度设计。
TEST(CalculatorTest, MultiplyTwoNumbers) {
Calculator calc;
double result = calc.multiply(3.0, 2.5);
EXPECT_DOUBLE_EQ(result, 7.5);
}
TEST(CalculatorTest, DividePositiveNumbers) {
Calculator calc;
double result = calc.divide(10.0, 3.0);
EXPECT_NEAR(result, 3.3333, 0.0001);
}
小技巧:浮点数比较用EXPECT_DOUBLE_EQ或EXPECT_NEAR,别用EXPECT_EQ。否则你会被浮点精度问题折磨到怀疑人生。
实现:
double multiply(double a, double b) {
return a * b;
}
double divide(double a, double b) {
return a / b;
}
9.4 除零异常:TDD如何驱动异常设计
除法有个特殊场景——除数为0。这个在需求文档里可能只是一句话,但在代码里,你得明确行为:抛出异常?返回NaN?还是返回0?
我习惯用TDD来驱动这个决策。先写测试:
TEST(CalculatorTest, DivideByZeroThrows) {
Calculator calc;
EXPECT_THROW(calc.divide(5.0, 0.0), std::invalid_argument);
}
测试写好了,现在去实现。因为测试期望抛出异常,所以实现必须包含异常处理:
double divide(double a, double b) {
if (b == 0.0) {
throw std::invalid_argument("除数不能为0");
}
return a / b;
}
你看,测试驱动了异常处理的设计。如果没有这个测试,我可能就随手写个return 0了事——但那样用户会疯掉的。
注意:浮点数判断是否为0,最好用b == 0.0。虽然浮点有精度问题,但用户显式传入0.0时,这个判断是可靠的。如果你担心,也可以用std::abs(b) < 1e-10。
9.5 知识体系:TDD计算器的完整流程
下面这张图,是我做这个计算器时脑子里过的流程。你看,每个功能点都对应一个测试用例,测试用例又驱动了实现代码。
9.6 完整代码与测试清单
最后,我把完整的Calculator类贴出来。注意,这个类是通过TDD一步步"长"出来的,不是一次性写好的。
// calculator.h
#pragma once
#include <stdexcept>
class Calculator {
public:
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
double multiply(double a, double b) { return a * b; }
double divide(double a, double b) {
if (b == 0.0) {
throw std::invalid_argument("除数不能为0");
}
return a / b;
}
};
对应的测试清单:
| 测试用例 | 输入 | 期望输出 | 状态 |
|---|---|---|---|
| 加法-正数 | 3, 5 | 8 | ✅ 通过 |
| 减法-结果为负 | 3, 8 | -5 | ✅ 通过 |
| 减法-两个负数 | -5, -3 | -2 | ✅ 通过 |
| 乘法-浮点数 | 3.0, 2.5 | 7.5 | ✅ 通过 |
| 除法-正常 | 10.0, 3.0 | ≈3.3333 | ✅ 通过 |
| 除法-除零异常 | 5.0, 0.0 | 抛出异常 | ✅ 通过 |
我的经验:这个计算器虽然简单,但TDD的节奏感你体会到了吗?红-绿-重构,每一步都有明确的目标。写测试时,你是在思考"用户会怎么用";写实现时,你是在思考"怎么让测试通过"。这种思维切换,才是TDD真正的价值。
嗯,这一章就到这里。代码不多,但流程完整。你可以在自己的机器上跟着敲一遍,感受一下"先写测试再写代码"和"先写代码再补测试"的差别。相信我,试过之后你会爱上这种节奏的。