跳到主要内容

测试驱动开发

测试驱动开发(Test-Driven Development,TDD)用一个尚未满足的行为测试推动实现,再在测试保护下整理代码。它把需求变成可以执行的反馈,让开发者在写实现前先想清楚调用方式、输入和可观察结果。

理解 TDD 的重点是反馈是否有效:测试是否因目标行为缺失而失败,实现是否真的满足规则,重构是否保持已有行为。只记住“先写测试”还不够。

1. 面试核心​

先记住这几条,再读正文
  • TDD 按失败测试、最小实现、重构循环推进,每次处理一个可观察行为。测试列表会随发现继续补充。卡 · 从规则到下一条测试
  • 先看到失败,是为了确认测试能够发现尚未实现的行为;导入错误或环境失败不等于目标断言有效。卡 · 失败与最小实现
  • 最小实现满足当前行为后,还要增加能够区分错误实现的边界测试,测试通过只支持已经表达出来的规则。卡 · 从规则到下一条测试
  • 重构改变内部结构并保持外部行为,测试和业务实现都需要维护,不能为了通过而同步改掉合理的预期。卡 · 扩展边界与重构
  • 单元级 TDD 能推动接口和规则设计,但不能替代真实依赖、部署和性能验证。卡 · 设计反馈与局限

2. 从规则到下一条测试​

先把规则拆成可以观察的行为,再选择下一条最能澄清设计的测试。测试不应从当前实现复制期望值,否则错误逻辑可能在两边同时存在。

例如,假设一项简化运费规则已经确认:订单金额使用非负整数元表示,满 100 元运费为 0,否则为 10。这个示例只接受整数金额,币种、金额精度和输入类型转换不在函数职责内。

初始测试列表可以包含:

  • 99 元收取 10 元运费。
  • 100 元免运费,超过 100 元也免运费。
  • 0 元仍收取 10 元运费。
  • 负数金额被拒绝。

列表帮助我们看见待完成行为,但不必一次把所有实现写完。边界用例的选择依据见边界值分析。

3. 失败与最小实现​

将第一条测试保存为 test_shipping.py:

from shipping import shipping_fee


def test_below_threshold():
assert shipping_fee(99) == 10

为使模块能够加载,先在同目录创建 shipping.py,明确保留未实现行为:

def shipping_fee(amount):
return 0

运行 python -m pytest -q 应因 0 != 10 失败。这条失败说明测试确实比较了目标输出;如果只出现 ModuleNotFoundError,需要先修复文件或导入问题,不能将其当成运费断言已经有效的证据。

将 shipping.py 修改成下面的最小实现:

def shipping_fee(amount):
return 10

第一条测试现在通过。它还不能证明完整运费规则正确,因为固定返回 10 也能满足这一个输入。此时该做的是继续选择能区分行为的测试,而不是把一次通过当成完工。

“最小实现”不要求故意写很差的代码,也不意味着永远只返回常量。这里用常量展示当前证据的边界;对已经明确、简单的规则,可以直接写清楚表达该规则的实现。

4. 扩展边界与重构​

将测试文件替换为下面的完整测试,分别表达正常边界和拒绝条件:

import pytest
from shipping import shipping_fee


@pytest.mark.parametrize("amount, expected", [
(0, 10),
(99, 10),
(100, 0),
(101, 0),
])
def test_shipping_fee(amount, expected):
assert shipping_fee(amount) == expected


def test_negative_amount():
with pytest.raises(ValueError, match="amount must be nonnegative"):
shipping_fee(-1)

固定返回 10 的实现会有 3 项失败:100、101 的期望运费不符,负数没有抛异常。失败信息对应新加入的行为,说明测试能够区分这个已知错误实现。

把 shipping.py 改为:

def shipping_fee(amount):
if amount < 0:
raise ValueError("amount must be nonnegative")
return 0 if amount >= 100 else 10

现在 5 项测试应全部通过。这里的参数化展开和异常断言由 pytest执行;TDD 关心的是这些测试分别推动了什么行为。

接下来才考虑是否有值得重构的结构。例如规则被多处引用时,可把阈值和普通运费提成命名常量,或者消除真实存在的重复。不需要为了证明做过重构而增加类、接口和多层包装。

重构前后都运行相同的有效测试。如果业务规则从满 100 变成满 200,这是行为变更,需要重新确认预期并调整测试;它不属于保持行为的重构。

5. 设计反馈与局限​

测试很难建立,有时说明待测行为与环境耦合过紧。例如计算运费的函数同时读取数据库、获取当前时间并发送通知,测试每个金额边界都要准备整套服务。

可以把计算规则与外部读写分开,让确定性规则直接接受输入、返回结果,外层再负责获取数据和执行副作用。这种拆分有明确作用:使规则更容易独立验证,也让失败更容易定位。

但测试容易写并不自动等于设计合理。为了方便 mock 而增加很多没有业务职责的接口,或者让测试断言每一个内部方法调用,都会使实现调整牵动大量测试。只有交互本身就是约定时,调用次数和参数才适合作为主要断言,边界见测试替身。

此外,规则单元测试无法确认真实数据库约束、网络协议或生产配置。需要按风险补充集成、契约和关键流程测试,范围划分见测试金字塔。探索新 API 或性能方案时,也可以先做小实验理解行为,再把已确认的规则固化为测试。

6 · 面试题卡片​

本页题目与参考回答

6.1 TDD 的一个完整循环包含什么?​

考察重点:失败、实现、重构与逐步反馈。

相关内容:从规则到下一条测试、失败与最小实现、扩展边界与重构。

参考回答

先为下一项行为写测试并确认它合理失败,再实现使其通过,最后在测试保护下整理结构。 然后从测试列表选择下一个行为,重复循环。

测试先行促使调用方式和结果更明确,重构则避免实现随着小步修改不断累积重复和耦合。只写测试再堆代码、从不整理结构,并未完成这个循环。

6.2 为什么 TDD 要先观察测试失败,导入报错算不算有效失败?​

考察重点:失败原因与目标行为的对应关系。

相关内容:失败与最小实现。

参考回答

先观察失败,是为了确认测试能够发现目标行为尚未满足。 导入失败说明准备或接口还不完整,但不能证明业务断言已经执行并有效。

应检查失败发生在哪一步,让测试能够到达目标调用,再确认错误结果被断言捕获。测试从一开始就通过时,也要判断该行为是否已有实现,或测试是否遗漏了真正需要检查的结果。

6.3 一个输入通过后,怎样选择下一条测试?​

考察重点:需求规则、错误实现与区分能力。

相关内容:从规则到下一条测试、扩展边界与重构。

参考回答

选择能表达尚未验证规则、并区分合理错误实现的输入,例如阈值两侧和拒绝条件。 固定返回 10 能通过 99 元用例,100 元免运费就能区分它。

期望值来自已确认规则,不能调用同一段实现重新计算。测试列表还应随边界和依赖的新发现继续补充。

6.4 重构时测试失败,能否直接把期望改成新输出?​

考察重点:外部行为、实现细节与测试维护。

相关内容:扩展边界与重构、设计反馈与局限。

参考回答

不能直接改,重构应保持外部行为,先判断失败来自真实行为变化还是测试绑定了不必要的实现细节。 前者要修复实现,后者可以调整测试结构并保留有效行为断言。

如果需求本身改变,需要重新确认规则后同时调整实现与测试,并将其视为行为变更。不能仅因为新输出出现,就把它当成正确答案。

6.5 单元测试按 TDD 全部通过,还需要集成测试吗?​

考察重点:局部规则证据与真实依赖边界。

相关内容:设计反馈与局限。

参考回答

需要,单元测试通常不能证明真实数据库、协议、配置和多个组件协作正确。 被替换的依赖只提供模拟条件下的证据。

应按风险补充真实集成、契约和关键用户流程,并独立检查性能与环境约束。TDD 是开发反馈方式,不是所有质量证据的替代品。

7. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明