软件项目开发全流程 · 第 20 课 · 单元测试与 TDD:红-绿-重构
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 轮 · 红:
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、测试的可维护性
测试也是代码,也会腐化。三条纪律:
- 测行为,不测实现:断言"输出是 108",而不是"内部调用了
_calc方法"。否则一次重构就全红。 - 一个测试只测一件事:测试名说清场景(
test_discount_below_threshold_returns_zero),失败了立刻知道哪坏了。 - 别复制粘贴测试:用工厂函数/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%;
- 金额永不为负。
- 严格 TDD:每个需求先写失败测试(红),再写最少代码(绿),必要时重构。
- 记录循环:每轮记录"测试名 / 红的原因 / 绿做了啥",至少 8 轮。
- 边界用例:补上"空购物车""金额刚好 100""折扣叠加"的边界测试。
- 重构:测试全绿后,把重复逻辑抽成函数,跑一遍确认仍绿。
- 反例:故意把某行改错,看测试是否捕获到(红);改回(绿)。
- 反思:TDD 让你多花了时间,还是省了时间?在哪一步最能感觉到它的价值?
下一课补上金字塔的中间层和顶层:集成测试与端到端测试。
9、今天你带走的三句话
- 单元测试 = Arrange / Act / Assert + FIRST 原则,用测试替身隔离外部依赖。
- TDD = 红 → 绿 → 重构 的小步循环,最大价值是"逼你先想清楚需求和接口"。
- 测行为、别测实现——否则重构时,测试会变成负担而不是安全网。
下一课:集成测试与端到端测试。