跳到主要内容

测试驱动开发(TDD)

测试驱动开发(Test-Driven Development,简称 TDD)是一种软件开发技术,它通过先编写测试来指导软件开发。TDD 由 Kent Beck 在 1990 年代末作为极限编程(Extreme Programming)的一部分提出,现已成为敏捷开发的核心实践之一。

什么是 TDD?

TDD 的核心理念是「测试先行」——在编写生产代码之前,先编写失败的测试,然后编写刚好足够的代码使测试通过,最后重构代码以保持整洁。

Martin Fowler 对 TDD 的定义是:TDD 是一种通过编写测试来指导软件构建的技术。本质上,我们反复遵循三个简单的步骤:

  1. 为想要添加的下一段功能编写测试
  2. 编写功能代码直到测试通过
  3. 重构新旧代码使其结构良好

TDD 与传统开发的区别

传统开发流程中,测试往往被视为开发的附属品,容易被忽视或推迟。TDD 将测试提升为开发的核心驱动力,确保代码始终处于可测试状态。

为什么选择 TDD?

优势说明
设计驱动先思考接口如何使用,再实现功能,促进更好的设计
即时反馈每次修改后立即知道是否正确,减少调试时间
安全重构有测试保护,可以大胆重构而不担心破坏功能
活文档测试代码展示了系统如何工作,是最可靠的文档
减少缺陷测试覆盖率高,问题在早期被发现和修复
增强信心对代码质量有信心,发布更有把握

红-绿-重构循环

TDD 的核心是红-绿-重构循环,这是一个持续迭代的过程:

第一步:红(Red)—— 编写失败的测试

在这一步,我们编写一个描述期望行为的测试。此时测试必须失败,因为我们还没有实现功能。如果测试意外通过,说明测试本身有问题,或者功能已经存在。

# 测试还不存在的功能
def test_calculator_add_two_positive_numbers():
"""计算器应该能够将两个正数相加"""
calculator = Calculator()
result = calculator.add(2, 3)
assert result == 5

运行测试,预期失败:

NameError: name 'Calculator' is not defined

这个失败是预期的,它告诉我们需要创建 Calculator 类并实现 add 方法。

第二步:绿(Green)—— 使测试通过

编写刚好足够的代码使测试通过。注意不要过度设计,只实现当前测试要求的功能。

# 刚好足够的实现
class Calculator:
def add(self, a, b):
return a + b

运行测试,现在通过了:

test_calculator_add_two_positive_numbers PASSED

第三步:重构(Refactor)—— 改进代码

测试通过后,审视代码,寻找可以改进的地方。重构时不必担心破坏功能,因为有测试保护。

# 重构后的代码(这个例子本身很简单,可能不需要重构)
class Calculator:
"""简单计算器类"""

def add(self, a: int, b: int) -> int:
"""返回两个数的和"""
return a + b

TDD 详细流程

测试清单

在开始编码之前,先列出需要实现的测试用例清单。这个清单在开发过程中会不断更新。

## 计算器功能测试清单
- [ ] 两个正数相加
- [ ] 正数加负数
- [ ] 两个负数相加
- [ ] 加零
- [ ] 两个数相减
- [ ] 两个数相乘
- [ ] 除法运算
- [ ] 除以零应抛出异常

选择测试顺序

测试顺序很重要,应该选择能够快速驱动设计的关键测试。通常遵循以下原则:

  1. 从简单到复杂:先实现基础功能,再处理边界情况
  2. 正向路径优先:先确保主要功能正常,再处理异常情况
  3. 驱动设计:选择能帮助你做出设计决策的测试

完整示例:FizzBuzz

让我们通过 FizzBuzz 问题来演示完整的 TDD 流程:

问题:编写一个函数,对于给定的数字:

  • 如果能被 3 整除,返回 "Fizz"
  • 如果能被 5 整除,返回 "Buzz"
  • 如果能被 3 和 5 同时整除,返回 "FizzBuzz"
  • 其他情况返回数字本身的字符串形式

迭代 1:处理普通数字

# 第一个测试:普通数字返回自身
def test_fizzbuzz_returns_number_for_non_multiple():
assert fizzbuzz(1) == "1"
assert fizzbuzz(2) == "2"

运行测试,失败。实现最简单的代码:

def fizzbuzz(n):
return str(n)

测试通过。

迭代 2:处理 3 的倍数

# 添加测试:3 的倍数返回 Fizz
def test_fizzbuzz_returns_fizz_for_multiple_of_3():
assert fizzbuzz(3) == "Fizz"
assert fizzbuzz(6) == "Fizz"

运行测试,失败。添加实现:

def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
return str(n)

测试通过。

迭代 3:处理 5 的倍数

# 添加测试:5 的倍数返回 Buzz
def test_fizzbuzz_returns_buzz_for_multiple_of_5():
assert fizzbuzz(5) == "Buzz"
assert fizzbuzz(10) == "Buzz"

运行测试,失败。添加实现:

def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)

测试通过。

迭代 4:处理 3 和 5 的公倍数

# 添加测试:15 的倍数返回 FizzBuzz
def test_fizzbuzz_returns_fizzbuzz_for_multiple_of_15():
assert fizzbuzz(15) == "FizzBuzz"
assert fizzbuzz(30) == "FizzBuzz"

运行测试,失败(返回了 "Fizz")。修复实现:

def fizzbuzz(n):
if n % 15 == 0:
return "FizzBuzz"
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)

测试通过。

迭代 5:重构

重构代码,消除重复的取模运算:

def fizzbuzz(n):
"""FizzBuzz 函数"""
result = ""
if n % 3 == 0:
result += "Fizz"
if n % 5 == 0:
result += "Buzz"
return result or str(n)

所有测试仍然通过,重构成功。

参数化测试

使用参数化测试可以更高效地覆盖多种情况:

import pytest

@pytest.mark.parametrize("input,expected", [
(1, "1"),
(2, "2"),
(3, "Fizz"),
(5, "Buzz"),
(6, "Fizz"),
(10, "Buzz"),
(15, "FizzBuzz"),
(30, "FizzBuzz"),
])
def test_fizzbuzz(input, expected):
assert fizzbuzz(input) == expected

TDD 模式

经典 TDD(Chicago School)

经典 TDD 也称为「状态验证」风格,关注对象的状态变化。

# 经典 TDD 示例
class TestBankAccount:
def test_deposit_increases_balance(self):
# Arrange
account = BankAccount(initial_balance=100)

# Act
account.deposit(50)

# Assert - 验证状态
assert account.balance == 150

def test_withdraw_decreases_balance(self):
account = BankAccount(initial_balance=100)

account.withdraw(30)

assert account.balance == 70

def test_withdraw_insufficient_funds_raises_error(self):
account = BankAccount(initial_balance=50)

with pytest.raises(InsufficientFundsError):
account.withdraw(100)

Mockist TDD(London School)

Mockist TDD 也称为「行为验证」风格,关注对象之间的交互。

from unittest.mock import Mock, call

# Mockist TDD 示例
class TestOrderService:
def test_place_order_notifies_customer(self):
# Arrange
email_service = Mock()
payment_service = Mock()
payment_service.charge.return_value = {"status": "success"}

order_service = OrderService(email_service, payment_service)

# Act
order = order_service.place_order(
customer_email="[email protected]",
items=[{"product_id": 1, "price": 100}]
)

# Assert - 验证行为
email_service.send_confirmation.assert_called_once_with(
to="[email protected]",
order_id=order.id
)

两种风格对比

特点经典 TDDMockist TDD
验证方式状态验证行为验证
Mock 使用较少,主要测试真实对象大量使用 Mock
耦合度与实现细节耦合较低与实现细节耦合较高
测试稳定性更稳定Mock 配置不当可能导致脆弱
适用场景领域模型、业务逻辑外部依赖多的场景

各语言 TDD 实践

Python (pytest)

# calculator.py
class Calculator:
"""简单计算器"""

def add(self, a: float, b: float) -> float:
return a + b

def divide(self, a: float, b: float) -> float:
if b == 0:
raise ValueError("除数不能为零")
return a / b

# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
"""计算器 TDD 测试"""

@pytest.fixture
def calc(self):
return Calculator()

class TestAdd:
"""加法测试组"""

def test_add_positive_numbers(self, calc):
assert calc.add(2, 3) == 5

def test_add_negative_numbers(self, calc):
assert calc.add(-2, -3) == -5

def test_add_zero(self, calc):
assert calc.add(5, 0) == 5

class TestDivide:
"""除法测试组"""

def test_divide_normal(self, calc):
assert calc.divide(10, 2) == 5

def test_divide_by_zero_raises_error(self, calc):
with pytest.raises(ValueError, match="除数不能为零"):
calc.divide(10, 0)

Java (JUnit 5)

// Calculator.java
public class Calculator {

public int add(int a, int b) {
return a + b;
}

public double divide(double a, double b) {
if (b == 0) {
throw new IllegalArgumentException("除数不能为零");
}
return a / b;
}
}

// CalculatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("计算器测试")
class CalculatorTest {

private Calculator calculator;

@BeforeEach
void setUp() {
calculator = new Calculator();
}

@Nested
@DisplayName("加法测试")
class AdditionTests {

@Test
@DisplayName("两个正数相加")
void testAddPositiveNumbers() {
assertEquals(5, calculator.add(2, 3));
}

@Test
@DisplayName("两个负数相加")
void testAddNegativeNumbers() {
assertEquals(-5, calculator.add(-2, -3));
}
}

@Nested
@DisplayName("除法测试")
class DivisionTests {

@Test
@DisplayName("正常除法")
void testDivideNormal() {
assertEquals(5.0, calculator.divide(10, 2));
}

@Test
@DisplayName("除以零抛出异常")
void testDivideByZero() {
assertThrows(IllegalArgumentException.class,
() -> calculator.divide(10, 0));
}
}
}

JavaScript (Jest)

// calculator.js
class Calculator {
add(a, b) {
return a + b;
}

divide(a, b) {
if (b === 0) {
throw new Error('除数不能为零');
}
return a / b;
}
}

module.exports = Calculator;

// calculator.test.js
const Calculator = require('./calculator');

describe('Calculator', () => {
let calc;

beforeEach(() => {
calc = new Calculator();
});

describe('add', () => {
test('should add two positive numbers', () => {
expect(calc.add(2, 3)).toBe(5);
});

test('should add two negative numbers', () => {
expect(calc.add(-2, -3)).toBe(-5);
});
});

describe('divide', () => {
test('should divide normally', () => {
expect(calc.divide(10, 2)).toBe(5);
});

test('should throw error when dividing by zero', () => {
expect(() => calc.divide(10, 0)).toThrow('除数不能为零');
});
});
});

Go

// calculator.go
package calculator

import "errors"

func Add(a, b int) int {
return a + b
}

func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除数不能为零")
}
return a / b, nil
}

// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -2, -3, -5},
{"with zero", 5, 0, 5},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}

func TestDivide(t *testing.T) {
t.Run("normal division", func(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Divide(10, 2) = %f; want 5", result)
}
})

t.Run("divide by zero", func(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("expected error for division by zero")
}
})
}

TDD 最佳实践

1. 测试先行,绝不妥协

始终先写测试,再写实现代码。这是 TDD 的核心原则,不能省略。

# ❌ 错误:先写实现,后补测试
def add(a, b):
return a + b

# 后补的测试往往质量不高
def test_add():
assert add(1, 2) == 3

# ✅ 正确:先写测试
def test_add_two_positive_numbers():
# 这个测试现在会失败,因为我们还没有实现 add 函数
assert add(1, 2) == 3

# 然后实现
def add(a, b):
return a + b

2. 小步前进

每次只添加一个测试,只编写刚好足够的代码。不要一次性实现多个功能。

# ❌ 错误:一次添加太多功能
def test_calculator():
calc = Calculator()
assert calc.add(1, 2) == 3
assert calc.subtract(5, 3) == 2
assert calc.multiply(2, 3) == 6
assert calc.divide(10, 2) == 5

# ✅ 正确:一个测试一个功能
def test_add():
calc = Calculator()
assert calc.add(1, 2) == 3

# 测试通过后,再添加下一个测试
def test_subtract():
calc = Calculator()
assert calc.subtract(5, 3) == 2

3. 红灯必须失败

确保新写的测试确实失败。如果测试意外通过,检查测试逻辑是否正确。

# 确保测试在实现前失败
def test_new_feature():
# 运行这个测试,确认它失败
# 如果它通过了,说明功能已存在或测试有问题
assert new_feature() == expected_value

4. 重构要彻底

不要跳过重构步骤。每次测试通过后,检查代码是否有改进空间。

# 重构前
def fizzbuzz(n):
if n % 15 == 0:
return "FizzBuzz"
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)

# 重构后:消除重复的取模运算
def fizzbuzz(n):
result = ""
if n % 3 == 0:
result += "Fizz"
if n % 5 == 0:
result += "Buzz"
return result or str(n)

5. 保持测试简洁

测试代码应该简单明了,避免复杂的逻辑。

# ❌ 复杂的测试
def test_complex():
data = []
for i in range(100):
if i % 2 == 0:
data.append(i * 2)
else:
data.append(i * 3)
result = process(data)
assert len(result) > 0 and all(x > 0 for x in result)

# ✅ 简洁的测试
def test_process_returns_positive_results():
result = process([1, 2, 3])
assert all(x > 0 for x in result)

TDD 反模式

1. 跳过红灯

直接编写实现代码,然后补写测试。这违背了 TDD 的初衷。

# ❌ 反模式:跳过红灯步骤
def feature():
return "implemented"

# 然后补写测试
def test_feature():
assert feature() == "implemented"

2. 过度设计

在绿灯阶段实现超出测试要求的功能。

# ❌ 反模式:过度设计
# 测试只要求 add(1, 2) == 3
def add(a, b):
# 却实现了类型检查、日志等额外功能
if not isinstance(a, (int, float)):
raise TypeError("a must be a number")
if not isinstance(b, (int, float)):
raise TypeError("b must be a number")
logger.info(f"Adding {a} and {b}")
return a + b

# ✅ 正确:只实现测试要求的功能
def add(a, b):
return a + b

3. 跳过重构

测试通过后不进行重构,导致代码质量下降。

# ❌ 反模式:跳过重构
# 代码通过了测试,但结构混乱
def process(x):
if x > 0:
if x < 10:
return x * 2
else:
return x
else:
return 0

# ✅ 正确:重构改进代码
def process(x):
if x <= 0:
return 0
if x < 10:
return x * 2
return x

4. 测试私有方法

TDD 关注公开接口,不应直接测试私有方法。

# ❌ 反模式:测试私有方法
def test_private_helper():
obj = MyClass()
assert obj._helper() == expected # 不要这样做

# ✅ 正确:通过公开接口测试
def test_public_interface():
obj = MyClass()
assert obj.public_method() == expected # 私有方法会被间接测试

TDD 的局限性

不适合 TDD 的场景

场景原因
探索性编程目标不明确,难以预先定义测试
一次性脚本投入产出比不高
UI 原型界面变化频繁,测试维护成本高
紧急修复时间紧迫,可能需要先修复再补测试

TDD 的挑战

  • 学习曲线:需要时间掌握 TDD 的思维方式和技巧
  • 初期投入:短期内可能感觉开发速度变慢
  • 团队协作:需要团队成员都理解和接受 TDD
  • 遗留代码:在缺乏测试的代码库中应用 TDD 较困难

BDD 与 TDD

行为驱动开发(Behavior-Driven Development,BDD)是 TDD 的演进,强调从业务角度描述系统行为。

# TDD 风格
def test_withdraw_insufficient_funds():
account = Account(100)
with pytest.raises(InsufficientFundsError):
account.withdraw(150)

# BDD 风格(使用 pytest-bdd)
from pytest_bdd import scenario, given, when, then

@scenario('account.feature', 'Withdraw with insufficient funds')
def test_withdraw_insufficient_funds():
pass

@given('an account with balance 100')
def account():
return Account(100)

@when('I withdraw 150')
def withdraw(account):
with pytest.raises(InsufficientFundsError):
account.withdraw(150)

@then('the withdrawal should fail')
def verify_failure():
pass # 异常已被捕获验证

参考资源

下一步

继续学习: