从 TDD 到 TDD²:测试驱动开发的进阶实践
从 TDD 到 TDD²:测试驱动开发的进阶实践
很多人知道 TDD(Test-Driven Development),但大部分停留在"红-绿-重构"的表面理解上。真正用 TDD 写过几个项目后,你会发现它远不止三个步骤那么简单。
这篇博客不是 TDD 入门教程,而是一份实战进阶指南。我会聊聊在实际项目中遇到的坑、总结出来的模式,以及如何让 TDD 真正为你所用,而不是成为负担。
为什么大多数 TDD 实践会失败
先直面一个现实:很多团队和个人尝试 TDD 后,最终都放弃了。原因不是 TDD 本身不好,而是没有掌握正确的节奏。
测试粒度过细
最经典的问题:把每一个内部方法都写成测试。
// ❌ 坏的例子:测试私有方法
public class OrderService {
private BigDecimal calculateTax(Order order) { ... }
private boolean validateStock(Order order) { ... }
private String generateOrderNumber() { ... }
}
每个私有方法写一个测试类,重构时全得改。正确的做法是测试公开行为,不是测试内部实现。
在错误层级做断言
# ❌ 太细,绑定实现
def test_calculate():
result = calculator.add(1, 2)
assert result == 3
assert calculator.history == [3] # 绑定内部状态
测试应该断言行为的输出,而不是实现细节。改一下:
# ✅ 关注行为
def test_calculate():
result = calculator.add(1, 2)
assert result == 3
一步走太大
TDD 的精髓是小步快跑。但很多人在"红"阶段写了太多代码,然后发现测试怎么都过不了。
# ❌ 一步到位,red 阶段写了整个模块
def test_checkout():
cart = Cart()
cart.add_item("book", 2, 29.9)
result = checkout(cart, coupon="NEW50", address="...")
assert result.total == 29.9
assert result.tracking_number is not None
这样如果测试失败,你完全不知道是哪一步出错了。应该拆成更小的步骤。
第二个 T:三角测量法
TDD² 中的第二个 T 来自 Transformation Priority(转换优先级)和 Triangulation(三角测量)。这是很多 TDD 实践者忽略的精华。
三角测量的本质
三角测量是通过多个测试案例来推导出通用实现,而不是实现完再写第二个用例。
import pytest
# 第一步:最简单的案例
def test_sum_empty():
assert sum_list([]) == 0
# 第二步:单元素列表
def test_sum_single():
assert sum_list([5]) == 5
# 第三步:多元素列表——三角测量点
def test_sum_multiple():
assert sum_list([1, 2, 3]) == 6
实现过程:
# 第一版:硬编码
def sum_list(nums):
return 0
# 第二版:还是硬编码
def sum_list(nums):
return 5 if nums == [5] else 0 # ❌ 这是反面例子,但真实开发中要注意它
# 更好的路径
def sum_list(nums):
if not nums:
return 0
return nums[0] # 稍微通用一点
# 最终版:经过三角测量推导出
def sum_list(nums):
return sum(nums)
关键点:三角测量让你避免过度工程化。只写刚好能让所有测试通过的代码。
转换优先级原则
Uncle Bob 提出了一个转换优先级列表,告诉你从"特殊到通用"的转换路径:
nil → constant(空→常量)constant → constant+(常量→更通用的常量)constant → scalar(常量→变量)statement → statements(单语句→多语句)unconditional → conditional(无条件→条件)scalar → array(标量→数组)array → container(数组→容器)statement → recursion(语句→递归)conditional → loop(条件→循环)expression → function(表达式→函数)variable → assignment(变量→赋值)case → if/elseorcase → polymorphism(case→分支/多态)
# 实际例子:猜数字游戏
def guess_number(secret: int, guess: int) -> str:
# 测试1:猜中
def test_guess_correct():
assert guess_number(5, 5) == "correct"
# 按转换优先级,应该先走 constant → scalar
def guess_number(secret: int, guess: int) -> str:
return "correct" # nil → constant
# 测试2:猜大了
def test_guess_too_high():
assert guess_number(5, 7) == "too high"
assert guess_number(5, 6) == "too high"
# 现在需要 conditional 了(优先级5)
def guess_number(secret: int, guess: int) -> str:
if guess > secret:
return "too high"
return "correct"
# 测试3:猜小了
def test_guess_too_low():
assert guess_number(5, 3) == "too low"
# 最终
def guess_number(secret: int, guess: int) -> str:
if guess > secret:
return "too high"
if guess < secret:
return "too low"
return "correct"
每一步都是最小幅度的转换,让代码自然生长。
测试替身的正确用法
测试替身(Mock/Stub/Fake)用得好是利器,用不好是累赘。很多人的误区是"只要测试就 mock 数据库"。
Stub vs Mock vs Fake
先说清楚区别:
- Stub:提供预设的返回值,不验证交互
- Mock:验证交互方式(比如有没有调用某个方法)
- Fake:轻量级实现替代(比如用内存数据库代替真实数据库)
from unittest.mock import Mock, patch
from dataclasses import dataclass
# Stub:返回固定数据
class UserServiceStub:
def get_user(self, user_id: int):
return {"id": user_id, "name": "Alice"}
# Fake:真正的替代实现
class InMemoryUserRepository:
def __init__(self):
self._users = {}
def save(self, user):
self._users[user.id] = user
def find_by_id(self, user_id):
return self._users.get(user_id)
# 测试时
def test_create_user():
repo = InMemoryUserRepository()
service = UserService(repo)
user = service.create_user("Alice")
assert repo.find_by_id(user.id).name == "Alice"
什么时候用 Mock
可以用,但有节制。Mock 应该在以下场景使用:
# ✅ 合理的 Mock:外部系统边界的确认
@patch("myapp.services.email_sender")
def test_send_welcome_email(mock_sender):
service = UserService(email_sender=mock_sender)
service.register("alice@example.com")
# 确认调用了外部服务
mock_sender.send.assert_called_once_with(
to="alice@example.com",
template="welcome"
)
# ❌ 过度 Mock:把内部实现细节全部 Mock
@patch("myapp.services.UserService._validate_email")
@patch("myapp.services.UserService._check_username")
@patch("myapp.services.UserService._hash_password")
def test_create_user(mock_hash, mock_check, mock_validate):
# 把自己内部逻辑全 mock 了还测什么?
pass
一个值得用的模式:Contract Test
接口定义好之后,用 Contract Test 保证实现一致性:
from abc import ABC, abstractmethod
class UserRepository(ABC):
@abstractmethod
def save(self, user): ...
@abstractmethod
def find_by_id(self, user_id): ...
# Contract Test
class UserRepositoryContract:
def test_save_and_find(self):
repo = self.create_repository()
user = User(id=1, name="Alice")
repo.save(user)
found = repo.find_by_id(1)
assert found.name == "Alice"
def test_find_nonexistent(self):
repo = self.create_repository()
assert repo.find_by_id(999) is None
# 子类实现这个方法来提供不同的实现
def create_repository(self) -> UserRepository:
raise NotImplementedError
# PostgreSQL 实现
class TestPostgresUserRepository(UserRepositoryContract):
def create_repository(self):
return PostgresUserRepository(db_url="postgresql://test:test@localhost:5432/test")
# 内存实现
class TestInMemoryUserRepository(UserRepositoryContract):
def create_repository(self):
return InMemoryUserRepository()
这样切换实现时,测试也复用。
重构的艺术
TDD 的第三步"重构"是最容易被跳过的。很多人写完通过后就跑了,留下了大量坏味道。
测试本身也需要重构
# ❌ 测试中有大量重复
def test_discount_no_coupon():
cart = Cart()
cart.add_item("book", 1, 29.9)
result = checkout(cart, coupon=None)
assert result.total == 29.9
def test_discount_with_10_percent():
cart = Cart()
cart.add_item("book", 1, 29.9)
result = checkout(cart, coupon="DISCOUNT10")
assert result.total == 26.91
def test_discount_with_20_percent():
cart = Cart()
cart.add_item("book", 1, 29.9)
result = checkout(cart, coupon="DISCOUNT20")
assert result.total == 23.92
提取公共逻辑:
import pytest
@pytest.fixture
def cart_with_book():
cart = Cart()
cart.add_item("book", 1, 29.9)
return cart
def test_discount_no_coupon(cart_with_book):
result = checkout(cart_with_book, coupon=None)
assert result.total == 29.9
@pytest.mark.parametrize("coupon,expected", [
("DISCOUNT10", 26.91),
("DISCOUNT20", 23.92),
("DISCOUNT50", 14.95),
])
def test_discount_with_coupon(cart_with_book, coupon, expected):
result = checkout(cart_with_book, coupon=coupon)
assert result.total == expected
生产代码重构的保险
TDD 的测试集就是重构的安全网。但要让它真正发挥作用:
# ✅ 重构前:先确认测试全部通过
import subprocess
def run_tests():
result = subprocess.run(["pytest", "-x", "--tb=short"], capture_output=True, text=True)
assert result.returncode == 0, f"Tests failed before refactoring:\n{result.stdout}"
# 然后放心重构
def calculate_discount(total: float, coupon: str | None) -> float:
"""把分散在各个地方的优惠计算集中到一处"""
if coupon is None:
return total
discounts = {
"DISCOUNT10": 0.9,
"DISCOUNT20": 0.8,
"DISCOUNT50": 0.5,
}
return total * discounts.get(coupon, 1.0)
重构后跑测试,如果全绿,说明重构成功。
边界测试:容易被忽略的角落
大部分测试覆盖的是"快乐路径",边界值往往被忽略。但生产环境的 bug 大多出在边界上。
数值边界
import pytest
def test_discount_boundaries():
# 零金额
assert checkout(Cart(), coupon="DISCOUNT10").total == 0
# 超大金额
big_cart = Cart()
big_cart.add_item("expensive_stuff", 1, 1_000_000)
result = checkout(big_cart, coupon="DISCOUNT50")
assert result.total == 500_000 # 不会溢出
# 负数金额(异常情况)
with pytest.raises(InvalidPriceError):
Cart().add_item("bad", 1, -100)
字符串边界
@pytest.mark.parametrize("username,expected", [
("a", True), # 最小有效
("a" * 50, True), # 最大有效
("", False), # 空字符串
("a" * 51, False), # 超长
("user@name", False), # 特殊字符
(None, False), # None
])
def test_username_validation(username, expected):
assert validate_username(username) == expected
并发边界
import threading
import time
def test_concurrent_checkout():
cart = Cart()
cart.add_item("limited_item", 1, 99.9)
# 库存只有1件
results = []
errors = []
def try_checkout(user_id):
try:
result = checkout(cart, user_id=user_id)
results.append(result)
except OutOfStockError:
errors.append(user_id)
threads = [
threading.Thread(target=try_checkout, args=(i,))
for i in range(10)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(results) == 1 # 只有一个人成功
assert len(errors) == 9 # 其他人获取"已售罄"
测试驱动设计:TDD 的真正价值
TDD 最大的好处不是"更少的 bug",而是更好的设计。
可测试性 = 可维护性
如果一个函数很难写测试,往往意味着它的设计有问题。
# ❌ 难以测试:硬编码依赖
def process_order(order_id):
db = Database("mysql://...")
order = db.query(f"SELECT * FROM orders WHERE id = {order_id}")
email_client = EmailClient("smtp://...")
email_client.send(order.user_email, "Order processed")
return "OK"
改为依赖注入后,不仅可测试,还更灵活:
# ✅ 可测试设计:依赖注入
def process_order(
order_id: int,
db: Database | None = None,
email: EmailClient | None = None
):
db = db or Database()
email = email or EmailClient()
order = db.find_order(order_id)
email.send(order.user_email, "Order processed")
return "OK"
# 测试中传递 mock
def test_process_order():
mock_db = Mock()
mock_db.find_order.return_value = Order(user_email="test@test.com")
mock_email = Mock()
result = process_order(1, db=mock_db, email=mock_email)
assert result == "OK"
mock_email.send.assert_called_once()
测试驱动的 API 设计
写客户端代码之前先写测试,逼你从使用者角度思考 API:
# 先写使用场景
from mylib import DataProcessor
import pytest
def test_data_processor_pipeline():
data = [1, 2, 3, 4, 5]
pipeline = DataProcessor()
result = (
pipeline
.load(data)
.filter(lambda x: x > 2)
.map(lambda x: x * 2)
.reduce(lambda a, b: a + b)
)
assert result == 28 # (3+4+5) * 2 还是 3*2 + 4*2 + 5*2?
写成这样你才清楚 API 到底要怎样设计才自然。
常见误区一览
| 误区 | 正确做法 |
|---|---|
| 测试私有方法 | 只测试公开行为 |
| 一个测试走完整流程 | 拆成小步,每个测试只验证一件事 |
| 过度 Mock 一切 | 只在外部系统边界用 Mock,内部用 Fake |
| 测试通过后跳过重构 | 红-绿-重构缺一不可 |
| 不测边界值 | 边界值是高发 bug 区,必须覆盖 |
| 写代码前不写测试 | TDD 的核心是测试先行,先设计再实现 |
上面这些坑我自己都踩过。TDD 不是死板的流程,而是一套反馈循环——写测试让你先想清楚要什么,实现让它变绿,重构让代码保持整洁。三者缺一不可。
写在最后
TDD 不容易,它需要练习和耐心。但一旦掌握了正确的节奏,你会发现:
- 编写测试让你先思考设计,而不是一头扎进实现
- 三角测量和转换优先级帮你写出刚好够用的代码
- 测试替身让你隔离关注点,但不滥用
- 重构不是可选项,它是 TDD 的核心环节
- 边界测试保护你不在小角落里翻车
TDD 是一种技能,不是看来的。动手写吧。
如果你对某一部分特别感兴趣,或者在实际项目中遇到过 TDD 的坑,欢迎留言讨论。