Skip to content

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: 0

API 开发的 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(&quot;$DESCRIPTION$&quot;)
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 --coverage

TDD 度量和报告

测试覆盖率

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 故障排除

常见问题

  1. 测试太慢

    • 使用内存数据库(H2)
    • Mock 外部依赖
    • 并行运行测试
  2. 测试不稳定

    • 避免依赖执行顺序
    • 清理测试数据
    • 使用 @DirtiesContext
  3. 难以测试的代码

    • 重构以提高可测试性
    • 使用依赖注入
    • 避免静态方法

总结

TDD 工作流的关键点:

  1. 🔴 先写测试: 明确需求和预期
  2. 🟢 最小实现: 只写让测试通过的代码
  3. 🔧 持续重构: 保持代码质量
  4. 🔄 快速迭代: 小步前进,频繁验证

通过严格遵循 TDD 工作流,我们能够:

  • 提高代码质量
  • 减少缺陷
  • 改善设计
  • 增强信心

SOLO Development Guide