1、一个"手快"的代价

小李写了个价格计算函数,逻辑很绕(满减、折扣、税费层层叠加)。他"手动跑了一下"发现能算对,就提交了。

三周后,运营改了个折扣规则。小李改了一行,没测——结果某些订单金额算错了,直到月底对账才发现,损失不小。

如果他当初写了 5 个单元测试,改完跑一下,10 秒就能发现。 本课讲清楚:单元测试怎么写,以及一种"先写测试"的方法——TDD。

2、单元测试的三要素

一个好的单元测试 = 准备(Arrange)+ 执行(Act)+ 断言(Assert):

def test_apply_coupon_reduces_total():
    # Arrange 准备:构造输入和环境
    cart = Cart(items=[Item("book", 100), Item("pen", 20)])
    coupon = Coupon(type="percent", value=10)

    # Act 执行:调用被测函数
    total = cart.apply_coupon(coupon)

    # Assert 断言:验证结果
    assert total == 108.0     # (100+20) * 0.9

三要素的价值:结构清晰,一眼看出"测什么、怎么测、期望啥"。

好测试的特征(FIRST 原则):

原则含义
Fast快——毫秒级,才能天天跑
Independent独立——不依赖别的测试、不依赖顺序
Repeatable可重复——任何环境跑结果一致
Self-validating自验证——自动判断成败,不靠人看
Timely及时——和代码一起写,别拖

3、隔离与测试替身(Test Double)

单元测试要隔离外部依赖——不真的连数据库、不真的发邮件、不真的调支付。替代品统称测试替身:

替身作用例子
Stub返回预设值get_user() 永远返回假用户
Mock验证"是否被调用"断言"发送了 1 封邮件"
Fake简化版真实实现用内存字典代替数据库
Spy记录调用信息记录支付接口被调了几次
class FakeEmailSender:
    def __init__(self):
        self.sent = []
    def send(self, to, body):
        self.sent.append((to, body))      # 不真发,只记录

def test_register_sends_welcome_email():
    sender = FakeEmailSender()
    register_user("a@b.com", password="x", email_sender=sender)
    assert len(sender.sent) == 1
    assert sender.sent[0][0] == "a@b.com"

关键技巧:依赖注入——把依赖作为参数传进去,测试时换成替身:

def register_user(email, password, email_sender=RealEmailSender()):
    user = User(email)
    user.save()
    email_sender.send(email, "欢迎注册")
    return user
警告:Mock 用多了会"测了一个假系统"。如果测试里满是 Mock,说明设计可能有问题(耦合太紧),或者该用集成测试(第 21 课)。

4、TDD:先写测试,再写代码

TDD(Test-Driven Development,测试驱动开发) 的循环,只有三步:

   红(Red)        绿(Green)          重构(Refactor)
写一个会失败的   →  写最少代码让它通过  →  在测试保护下
测试(还没实现)       (可能很丑)          改善代码结构
      ↑                                        │
      └────────────────────────────────────────┘

"为什么先写测试"的三个理由:

  1. 逼你想清楚需求:测试就是"这段代码该怎么被调用",写测试=先设计接口。
  2. 测试一定会被写:别人(包括未来的你)不会回头补测试,先写才不会偷懒。
  3. 重构有安全网:绿了以后,你才敢随便改结构。

一个完整示例:实现一个"判断闰年"的函数。

第 1 轮 · 红:

def test_year_2024_is_leap():
    assert is_leap(2024) is True

(还只有一个失败的测试——is_leap 还没定义)

第 2 轮 · 绿(写最少代码):

def is_leap(year):
    return True          # 最偷懒的实现,只要能过测试

第 3 轮 · 再加一个测试(红):

def test_year_2023_is_not_leap():
    assert is_leap(2023) is False

绿:

def is_leap(year):
    return year % 4 == 0

继续加案例(红→绿),直到 1900、2000 这些边界:

def test_year_1900_is_not_leap():    # 百年不闰
    assert is_leap(1900) is False
def test_year_2000_is_leap():        # 四百年再闰
    assert is_leap(2000) is True

最终实现 + 重构:

def is_leap(year):
    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

注意发生了什么:测试逼着你一步步补全了边界逻辑(1900、2000),而不是靠"想"。这就是 TDD 的威力——它把"想清楚"变成"写不出测试就说明没想清楚"。

5、TDD 的收益与争议

收益:

  • 测试覆盖率高(因为先写的)。
  • 接口设计更自然(先想"怎么用")。
  • 调试时间大幅减少(红灯一亮就知道哪坏了)。
  • 重构有底气(第 18 课)。

争议与误用:

  • ❌ "TDD 就是先写所有测试再写代码":不是,是小步循环(红→绿→红→绿)。
  • ❌ "TDD 保证没 bug":不保证,只保证你写下来的场景是对的。
  • ❌ 不适合所有场景:探索性的算法、UI 原型、不确定的接口,硬套 TDD 反而僵化。
  • ✅ 务实态度:核心逻辑用 TDD,探索性代码可以"先写后补测试"——但别永远补不上。
一句话态度:TDD 是工具不是教条。它最大的价值不是"测试",而是逼你把需求和接口想清楚。

6、测试的可维护性

测试也是代码,也会腐化。三条纪律:

  1. 测行为,不测实现:断言"输出是 108",而不是"内部调用了 _calc 方法"。否则一次重构就全红。
  2. 一个测试只测一件事:测试名说清场景(test_discount_below_threshold_returns_zero),失败了立刻知道哪坏了。
  3. 别复制粘贴测试:用工厂函数/fixture 造数据,不然改一个字段要改 50 个测试。
# 用 fixture 造数据,避免重复
def make_cart(items=None):
    return Cart(items or [Item("book", 100)])

7、常见误区

误区问题正解
测实现细节重构即红测对外行为
一个测试断言一堆失败不知哪条一测试一断言(核心)
依赖执行顺序单独跑就挂互不依赖,各自初始化
Mock 满天飞测了个假系统减少 Mock,必要时上集成测试
为覆盖率而写测 getter/setter 凑数测有价值的分支与边界

8、动手实验 🎯

任务:用 TDD 实现一个「购物车优惠计算器」。

需求(逐步加):

  • 商品列表求和;
  • 满 100 减 10;
  • 折扣码打 9 折;
  • 会员再减 5%;
  • 金额永不为负。
  1. 严格 TDD:每个需求先写失败测试(红),再写最少代码(绿),必要时重构。
  2. 记录循环:每轮记录"测试名 / 红的原因 / 绿做了啥",至少 8 轮。
  3. 边界用例:补上"空购物车""金额刚好 100""折扣叠加"的边界测试。
  4. 重构:测试全绿后,把重复逻辑抽成函数,跑一遍确认仍绿。
  5. 反例:故意把某行改错,看测试是否捕获到(红);改回(绿)。
  6. 反思:TDD 让你多花了时间,还是省了时间?在哪一步最能感觉到它的价值?

下一课补上金字塔的中间层和顶层:集成测试与端到端测试。

9、今天你带走的三句话

  1. 单元测试 = Arrange / Act / Assert + FIRST 原则,用测试替身隔离外部依赖。
  2. TDD = 红 → 绿 → 重构 的小步循环,最大价值是"逼你先想清楚需求和接口"。
  3. 测行为、别测实现——否则重构时,测试会变成负担而不是安全网。

下一课:集成测试与端到端测试。

标签: 软件工程, 项目管理, 开发流程

添加新评论