TDD 工作流
测试驱动开发的完整实践流程
TDD 核心循环
TDD 的核心是 红-绿-重构 循环:
详细工作流程
第一步:编写失败的测试(Red)
1.1 分析需求
markdown
## 需求:用户注册功能
- 接收邮箱、密码、用户名
- 邮箱必须唯一
- 密码至少8位
- 返回创建的用户信息
- 发送欢迎邮件1.2 编写测试用例
java
// UserServiceTest.java
@Test
@DisplayName("应该成功注册新用户")
void shouldRegisterNewUser() {
// Given - 准备测试数据
RegisterRequest request = RegisterRequest.builder()
.email("test@example.com")
.password("securePass123")
.username("testuser")
.build();
// When - 执行操作
User user = userService.register(request);
// Then - 验证结果
assertNotNull(user);
assertNotNull(user.getId());
assertEquals("test@example.com", user.getEmail());
assertEquals("testuser", user.getUsername());
assertTrue(passwordEncoder.matches("securePass123", user.getPassword()));
}
@Test
@DisplayName("邮箱已存在时应该抛出异常")
void shouldThrowExceptionWhenEmailExists() {
// Given
String existingEmail = "existing@example.com";
when(userRepository.existsByEmail(existingEmail)).thenReturn(true);
RegisterRequest request = RegisterRequest.builder()
.email(existingEmail)
.password("password123")
.username("newuser")
.build();
// When & Then
assertThrows(EmailAlreadyExistsException.class,
() -> userService.register(request));
}1.3 运行测试确认失败
bash
mvn test
# 输出
Tests run: 2, Failures: 2, Errors: 0, Skipped: 0
[ERROR] UserServiceTest.shouldRegisterNewUser:25
java.lang.NullPointerException: Cannot invoke "UserService.register()"第二步:编写最小代码(Green)
2.1 实现最小功能
java
// UserService.java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public User register(RegisterRequest request) {
// 最小实现 - 只让测试通过
User user = User.builder()
.id(UUID.randomUUID().toString())
.email(request.getEmail())
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.build();
return userRepository.save(user);
}
}2.2 添加异常处理
java
public User register(RegisterRequest request) {
// 检查邮箱是否存在
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException("邮箱已被注册");
}
User user = User.builder()
.id(UUID.randomUUID().toString())
.email(request.getEmail())
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.build();
return userRepository.save(user);
}2.3 运行测试确认通过
bash
mvn test
# 输出
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] All tests passed!第三步:重构优化(Refactor)
3.1 提取方法
java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
private final Validator validator;
public User register(RegisterRequest request) {
// 验证请求
validateRequest(request);
// 检查邮箱唯一性
checkEmailUniqueness(request.getEmail());
// 创建用户
User user = createUser(request);
// 保存用户
User savedUser = userRepository.save(user);
// 发送欢迎邮件
sendWelcomeEmail(savedUser);
return savedUser;
}
private void validateRequest(RegisterRequest request) {
Set<ConstraintViolation<RegisterRequest>> violations =
validator.validate(request);
if (!violations.isEmpty()) {
throw new ValidationException(violations);
}
}
private void checkEmailUniqueness(String email) {
if (userRepository.existsByEmail(email)) {
throw new EmailAlreadyExistsException(
String.format("邮箱 %s 已被注册", email)
);
}
}
private User createUser(RegisterRequest request) {
return User.builder()
.id(UUID.randomUUID().toString())
.email(request.getEmail().toLowerCase())
.username(request.getUsername())
.password(passwordEncoder.encode(request.getPassword()))
.createdAt(LocalDateTime.now())
.status(UserStatus.ACTIVE)
.build();
}
private void sendWelcomeEmail(User user) {
try {
emailService.sendWelcomeEmail(user.getEmail(), user.getUsername());
} catch (Exception e) {
// 邮件发送失败不影响注册
log.error("发送欢迎邮件失败: {}", e.getMessage());
}
}
}3.2 确保测试仍然通过
bash
mvn test
# 确保重构后所有测试仍然通过
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0API 开发的 TDD 流程
1. 控制器层 TDD
步骤 1:编写控制器测试
java
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
@DisplayName("POST /users 应该创建新用户")
void shouldCreateNewUser() throws Exception {
// Given
RegisterRequest request = new RegisterRequest(
"test@example.com", "password123", "testuser"
);
User createdUser = User.builder()
.id("123")
.email("test@example.com")
.username("testuser")
.build();
when(userService.register(any())).thenReturn(createdUser);
// When & Then
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/users/123"))
.andExpect(jsonPath("$.id").value("123"))
.andExpect(jsonPath("$.email").value("test@example.com"))
.andExpect(jsonPath("$.username").value("testuser"));
}
@Test
@DisplayName("POST /users 邮箱已存在时应返回409")
void shouldReturn409WhenEmailExists() throws Exception {
// Given
RegisterRequest request = new RegisterRequest(
"existing@example.com", "password123", "testuser"
);
when(userService.register(any()))
.thenThrow(new EmailAlreadyExistsException("邮箱已存在"));
// When & Then
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error").value("邮箱已存在"));
}
}步骤 2:实现控制器
java
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> register(
@Valid @RequestBody RegisterRequest request) {
User user = userService.register(request);
UserResponse response = UserResponse.builder()
.id(user.getId())
.email(user.getEmail())
.username(user.getUsername())
.build();
return ResponseEntity
.created(URI.create("/users/" + user.getId()))
.body(response);
}
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailExists(
EmailAlreadyExistsException e) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(e.getMessage()));
}
}2. 集成测试 TDD
java
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class UserIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("完整的用户注册流程")
void shouldCompleteUserRegistrationFlow() throws Exception {
// Given
RegisterRequest request = new RegisterRequest(
"integration@example.com",
"password123",
"integrationuser"
);
// When - 调用注册接口
MvcResult result = mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andReturn();
// Then - 验证响应
String responseBody = result.getResponse().getContentAsString();
UserResponse response = objectMapper.readValue(
responseBody, UserResponse.class
);
assertNotNull(response.getId());
// 验证数据库
Optional<User> savedUser = userRepository.findById(response.getId());
assertTrue(savedUser.isPresent());
assertEquals("integration@example.com", savedUser.get().getEmail());
// 验证邮件发送(通过 Mock)
verify(emailService).sendWelcomeEmail(
eq("integration@example.com"),
eq("integrationuser")
);
}
}TDD 最佳实践模式
1. AAA 模式
java
@Test
void testMethodName() {
// Arrange (Given) - 准备
String input = "test data";
ExpectedObject expected = new ExpectedObject();
// Act (When) - 执行
ActualObject actual = service.process(input);
// Assert (Then) - 断言
assertEquals(expected, actual);
}2. BDD 风格
java
@Test
@DisplayName("Given 有效的用户数据 When 调用注册 Then 返回新用户")
void givenValidUserData_whenRegister_thenReturnNewUser() {
// Given
RegisterRequest validRequest = createValidRequest();
// When
User result = userService.register(validRequest);
// Then
assertThat(result)
.isNotNull()
.hasFieldOrProperty("id")
.hasFieldOrPropertyWithValue("email", validRequest.getEmail());
}3. 参数化测试
java
@ParameterizedTest
@ValueSource(strings = {
"short", // 太短
"", // 空
"no-special-char", // 没有特殊字符
"NoNumber" // 没有数字
})
@DisplayName("无效密码应该验证失败")
void shouldFailValidationForInvalidPasswords(String password) {
// Given
RegisterRequest request = RegisterRequest.builder()
.email("test@example.com")
.password(password)
.username("testuser")
.build();
// When & Then
assertThrows(ValidationException.class,
() -> userService.register(request));
}
@ParameterizedTest
@CsvSource({
"test@example.com, true",
"invalid-email, false",
"@example.com, false",
"test@, false"
})
@DisplayName("邮箱格式验证")
void shouldValidateEmailFormat(String email, boolean isValid) {
// Given
RegisterRequest request = RegisterRequest.builder()
.email(email)
.password("validPass123!")
.username("testuser")
.build();
// When & Then
if (isValid) {
assertDoesNotThrow(() -> validator.validateEmail(email));
} else {
assertThrows(ValidationException.class,
() -> validator.validateEmail(email));
}
}TDD 工具集成
1. IDE 集成
IntelliJ IDEA 配置
xml
<!-- Live Templates -->
<template name="test" value="@Test
@DisplayName("$DESCRIPTION$")
void $METHOD_NAME$() {
// Given
$GIVEN$
// When
$WHEN$
// Then
$THEN$
}" description="JUnit 5 test method" toReformat="true" toShortenFQNames="true">
<variable name="DESCRIPTION" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="METHOD_NAME" expression="camelCase(DESCRIPTION)" defaultValue="" alwaysStopAt="true" />
</template>2. 测试运行配置
json
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "java",
"name": "Run Tests (TDD)",
"request": "launch",
"mainClass": "",
"projectName": "api-project",
"testConfig": {
"workingDirectory": "${workspaceFolder}",
"args": ["--enable-preview"],
"vmArgs": ["-ea"],
"env": {
"SPRING_PROFILES_ACTIVE": "test"
}
}
}
]
}3. 自动化测试脚本
bash
#!/bin/bash
# tdd-watch.sh - 持续运行测试
# 使用 nodemon 监控文件变化
nodemon --watch src \
--ext java \
--exec "mvn test -Dtest=*Test" \
--delay 1
# 或使用 gradle
./gradlew test --continuous
# 或使用 Jest (TypeScript/JavaScript)
jest --watch --coverageTDD 度量和报告
测试覆盖率
xml
<!-- pom.xml - JaCoCo 配置 -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.8</version>
<configuration>
<excludes>
<exclude>**/config/**</exclude>
<exclude>**/entity/**</exclude>
</excludes>
</configuration>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<limits>
<limit>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>测试报告生成
groovy
// build.gradle
test {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed"
}
reports {
html.enabled = true
junitXml.enabled = true
}
// 生成测试报告
finalizedBy jacocoTestReport
}
jacocoTestReport {
reports {
xml.enabled = true
html.enabled = true
csv.enabled = false
}
}TDD 故障排除
常见问题
测试太慢
- 使用内存数据库(H2)
- Mock 外部依赖
- 并行运行测试
测试不稳定
- 避免依赖执行顺序
- 清理测试数据
- 使用 @DirtiesContext
难以测试的代码
- 重构以提高可测试性
- 使用依赖注入
- 避免静态方法
总结
TDD 工作流的关键点:
- 🔴 先写测试: 明确需求和预期
- 🟢 最小实现: 只写让测试通过的代码
- 🔧 持续重构: 保持代码质量
- 🔄 快速迭代: 小步前进,频繁验证
通过严格遵循 TDD 工作流,我们能够:
- 提高代码质量
- 减少缺陷
- 改善设计
- 增强信心