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_EQEXPECT_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计算器的完整流程

下面这张图,是我做这个计算器时脑子里过的流程。你看,每个功能点都对应一个测试用例,测试用例又驱动了实现代码。

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真正的价值。

嗯,这一章就到这里。代码不多,但流程完整。你可以在自己的机器上跟着敲一遍,感受一下"先写测试再写代码"和"先写代码再补测试"的差别。相信我,试过之后你会爱上这种节奏的。

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