TDD实战指南:红-绿-重构循环与Mock/Stub使用
引言:为什么TDD值得坚持?
测试驱动开发(Test-Driven Development, TDD)并非新鲜概念,但在实际项目中真正坚持下来的团队并不多。很多人认为写测试会拖慢进度,实则TDD通过“先写测试、再写实现、最后重构”的节奏,能够显著减少调试时间、提升代码可维护性,并迫使开发者思考接口设计。本文将带你深入TDD的核心实践:红-绿-重构循环、测试金字塔,以及Mock与Stub的使用,并用JUnit + Mockito给出可运行的代码示例。
1. 红-绿-重构循环:TDD的心脏
TDD的每个最小单元都遵循三步:
- 红(Red):先写一个会失败的测试。这意味着你尚未实现功能,测试明确表达了期望的行为。
- 绿(Green):编写最简代码让测试通过。不追求完美,只求通过。
- 重构(Refactor):在测试的保护下,清理代码设计,消除重复,提升可读性。
这三个步骤循环往复,每次只解决一个小问题。下面通过一个简单的Calculator类演示。
示例:开始“红”阶段
// 测试类 CalculatorTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
@Test
void shouldAddTwoNumbers() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertEquals(5, result);
}
}
这时还没有Calculator类,编译失败——这是“红”状态。
进入“绿”阶段
编写最简实现:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
测试通过,进入“绿”。
重构阶段
当前代码足够简单,无需重构。如果后续发现重复逻辑或设计问题,就在测试通过后立即修改代码,再跑一次测试确保依然通过。
2. 测试金字塔:分层策略
为了保持测试高效且易维护,Mike Cohn提出的测试金字塔建议:
- 上端:端到端测试(E2E) 数量最少,模拟真实用户操作,覆盖关键场景但运行慢、脆皮。
- 中部:集成测试(Integration) 验证模块间协作,如数据库交互、外部服务调用。
- 底部:单元测试(Unit Test) 数量最多,运行快,隔离性强,是TDD的主力。
不要在单元测试中过度依赖真实数据库或远程服务,否则测试会变慢且不可控。这正是Mock和Stub的用武之地。
3. Mock与Stub:单元测试的隔离利器
在单元测试中,我们只关注被测类(SUT)的逻辑,而不关心其依赖的真实行为。Stub为测试提供预设的返回值,Mock则进一步验证交互(比如某个方法是否被调用、调用次数、参数等)。推荐使用Mockito框架简化Mock和Stub的创建。
场景:用户注册服务
假设有一个UserService,它依赖UserRepository(保存用户到数据库)和EmailService(发送欢迎邮件)。我们想测试UserService.register()的逻辑——检查用户名是否已存在,若不存在则保存并发送邮件。
不使用Mock的痛点
- 需要真实的数据库和邮件服务器,测试慢且不可重复。
- 数据库状态可能干扰断言。
使用Mockito重构
首先添加依赖(Maven):
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
实现被测类 UserService
public class UserService {
private UserRepository userRepo;
private EmailService emailService;
public UserService(UserRepository userRepo, EmailService emailService) {
this.userRepo = userRepo;
this.emailService = emailService;
}
public void register(String username, String email) {
if (userRepo.existsByName(username)) {
throw new IllegalArgumentException("Username already taken");
}
User user = new User(username, email);
userRepo.save(user);
emailService.sendWelcomeEmail(email);
}
}
编写Mockito测试:验证交互
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest {
@Mock
private UserRepository userRepo;
@Mock
private EmailService emailService;
private UserService userService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
userService = new UserService(userRepo, emailService);
}
@Test
void shouldRegisterNewUser() {
// Given: 用户名不存在
when(userRepo.existsByName("alice")).thenReturn(false);
// When: 注册
userService.register("alice", "alice@example.com");
// Then: 验证交互 —— 这是Mock的核心
verify(userRepo).save(any(User.class));
verify(emailService).sendWelcomeEmail("alice@example.com");
}
@Test
void shouldThrowExceptionWhenUsernameExists() {
// Given: 用户名已存在(Stub返回true)
when(userRepo.existsByName("bob")).thenReturn(true);
// When & Then: 期望抛出异常
assertThrows(IllegalArgumentException.class,
() -> userService.register("bob", "bob@example.com"));
// 验证:save和sendWelcomeEmail不会被调用
verify(userRepo, never()).save(any());
verify(emailService, never()).sendWelcomeEmail(anyString());
}
}
关键点解释:
@Mock创建Mock对象,不需要真实实现。when(...).thenReturn(...)是Stub:当调用existsByName时,返回指定值,控制依赖的行为。verify(...)是Mock验证:检查方法是否被调用、调用次数、参数。never()确保未调用。- 测试完全在内存中运行,毫秒级完成,且不依赖任何外部资源。
Stub vs Mock 的速记:Stub提供数据,Mock验证行为。一个Mock通常同时起到Stub的作用(提供预设返回值),但验证交互是它独有的能力。
4. 在TDD循环中融入Mock
回到红-绿-重构循环,写单元测试时优先使用Mock隔离依赖,让测试专注被测类的逻辑。例如:
- 红:编写
UserServiceTest,使用Mock的when和verify定义期望行为。 - 绿:在产品代码中直接实现
UserService,通过测试。 - 重构:优化产品代码(如提取方法、增加校验),重新运行测试确认。
注意:Mockito不能也不应被滥用。过度验证内部交互会导致测试脆弱(重构时容易因内部实现变化而失败)。遵从原则:只Mock属于你自己的接口或抽象,不要Mock你无法控制的外部库。优先测试输出结果,其次验证协作行为。
总结
TDD的“红-绿-重构”循环通过极短的反馈周期,迫使开发者写出可测试、接口清晰的代码。测试金字塔提醒我们单元测试应占据主流,而Mock与Stub解决了依赖隔离问题,让单元测试跑得又快又稳定。结合JUnit 5和Mockito,你可以轻松写出高质量的测试套件,真正用测试驱动出健壮的设计。
下一步行动建议:从你当前项目中选取一个业务类,尝试对它的核心方法应用TDD:先写测试(使用Mock),再写实现,最后重构。坚持几个循环后,你会感受到代码质量与开发效率的双重提升。

浙公网安备 33010602011771号