0 Comments

TDD 测试驱动开发实战指南

测试驱动开发(Test-Driven Development,简称 TDD)是软件工程领域中一个既经典又容易在实践中”走样”的方法论。很多人听说过它,甚至自认为在践行它,但真正掌握红-绿-重构节奏的团队却并不多。本文不是一篇理论综述,而是希望带你把手放在键盘上,真正体验一次”先写测试,再写实现”的完整过程。

什么是 TDD

TDD 的核心是一个极其简单的循环,被概括为三个词:Red、Green、Refactor

  1. Red(红):先写一个会失败的测试。这个测试描述了你期望的、尚未实现的行为。
  2. Green(绿):用尽可能简单的方式让测试通过,哪怕代码很丑。
  3. Refactor(重构):在测试保护下,清理代码、消除重复、改进设计,同时保持测试仍然通过。

这个循环的关键不在于”先写测试”这个动作本身,而在于它强制你把注意力从”怎么实现”转移到”期望什么行为”上。测试是你对需求的第一次、也是最可执行的一次描述。

与”先写代码再补测试”不同,TDD 中的测试是规格说明,而不是事后追加的保险。当一个测试是事后补写的,它往往只是在迎合已经写好的实现,测试的价值就大打折扣了。

从一个例子开始:一个购物车

我们以 Python 和 pytest 为例,实现一个简单的购物车功能。首先要明确我们的需求:购物车需要支持添加商品、计算总价,并且同一商品重复添加时合并数量。

第一步:Red

在写任何实现之前,我们先写测试。创建 test_cart.py

from cart import Cart


def test_empty_cart_total_is_zero():
    cart = Cart()
    assert cart.total() == 0


def test_add_single_item():
    cart = Cart()
    cart.add("apple", 3.5, 2)
    assert cart.total() == 7.0


def test_merge_duplicate_items():
    cart = Cart()
    cart.add("apple", 3.5, 1)
    cart.add("apple", 3.5, 2)
    assert cart.total() == 10.5
    assert cart.quantity("apple") == 3

现在运行 pytest,我们会得到三个失败的测试。注意,此时的失败并不是错误——它恰恰是 TDD 流程中”红”阶段应有的状态:我们明确地定义了还没有实现的目标。

第二步:Green

接下来,用最简单、最直接的代码让测试通过。不要考虑任何优化,不要去想”以后会不会有打折”。

class Cart:
    def __init__(self):
        self._items = {}

    def add(self, name, price, quantity=1):
        if name in self._items:
            existing_price, existing_qty = self._items[name]
            self._items[name] = (existing_price, existing_qty + quantity)
        else:
            self._items[name] = (price, quantity)

    def quantity(self, name):
        return self._items[name][1] if name in self._items else 0

    def total(self):
        return sum(price * qty for price, qty in self._items.values())

再次运行测试,三个测试全部通过。这就是”绿”阶段。代码虽然能用,但 _items 里用元组 (price, quantity) 来存数据,可读性差,将来维护容易出错。

第三步:Refactor

现在我们在测试的保护下重构。用一个 LineItem 类或者 dataclass 让数据结构更清晰:

from dataclasses import dataclass


@dataclass
class LineItem:
    name: str
    price: float
    quantity: int


class Cart:
    def __init__(self):
        self._items = {}

    def add(self, name, price, quantity=1):
        item = self._items.get(name)
        if item:
            item.quantity += quantity
        else:
            self._items[name] = LineItem(name, price, quantity)

    def quantity(self, name):
        item = self._items.get(name)
        return item.quantity if item else 0

    def total(self):
        return sum(item.price * item.quantity for item in self._items.values())

重构完成后再次运行测试——仍然全绿。这就是 TDD 最迷人的部分:你可以放心大胆地改代码,因为测试就是你的安全网。

新增需求:继续循环

TDD 的美妙之处在于它是一个持续循环。假设现在需求升级了:购物车需要支持折扣码。我们同样先写测试:

def test_apply_discount():
    cart = Cart()
    cart.add("apple", 3.5, 2)
    cart.apply_discount(0.5)  # 打五折
    assert cart.total() == 3.5


def test_discount_cannot_be_negative():
    cart = Cart()
    cart.add("apple", 10.0, 1)
    cart.apply_discount(-0.1)
    assert cart.total() == 10.0

先让它们失败(红),再实现(绿),再重构。你会发现,每当需求发生变化,TDD 都给你一个明确、无歧义的入口:先问”这个新行为长什么样”,用测试把它写下来。

测试的命名艺术

好的测试名是一句可读的规格说明。推荐两个主流习惯:

  1. test_<场景>_<条件>_<期望>:如 test_add_single_item
  2. Given-When-Then 风格:如 test_given_empty_cart_when_adding_item_then_total_updates

无论选哪种,关键是要让测试在没有注释的情况下也能自解释。一个读测试的人,应该能在几十秒内理解这个模块的全部行为契约。

TDD 的常见误区

误区一:追求 100% 覆盖率

覆盖率是一个指标,不是目标。为一行几乎不会出错、也没有业务逻辑的代码写测试,是在浪费维护成本。TDD 关注的是行为,而不是行数

误区二:跳过重构阶段

很多人完成了”红-绿”就停止了,认为重构是可选的。实际上,重构才是 TDD 真正价值的来源——它让代码在测试的护航下逐渐变好。不重构的 TDD,只是”测试优先编程”,长期会积累技术债。

误区三:一次写太多测试

一次只写一个测试,然后让它通过。写五个测试再一口气实现,会让你失去”每个测试验证一个明确增量”的节奏感,也难以定位失败原因。

实践建议

  • 从小的、纯逻辑的模块开始。别一上来就用 TDD 写复杂的 IO 或数据库交互,那会让你陷入 mock 的泥潭。
  • 针对外部依赖使用测试替身。对于数据库、网络请求,使用 mock 或 fake 隔离不确定性,专注验证业务逻辑。
  • 把测试当作文档。新人入职时,最该先看的不是实现代码,而是测试代码——它告诉了你系统”应该做什么”。

结语

TDD 不是银弹,它不能解决所有问题,也未必适合所有场景(比如探索性的原型开发)。但它提供了一种纪律:先用可测试的方式思考,再动手实现。这种”测试先行”的心态转变,往往比任何具体工具都更有价值。

当你真正跑起那条红-绿-重构的循环,你会慢慢发现:写测试不再是一件苦差事,而是一种让开发更从容、更有底气的习惯。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注