Skip to content

TDD 实践指南

测试驱动开发:先写测试,再写代码

什么是 TDD?

测试驱动开发(Test-Driven Development, TDD)是一种软件开发方法,它要求在编写实际代码之前先编写测试用例。TDD 的核心循环是:红灯-绿灯-重构。

TDD 核心循环

为什么要使用 TDD?

1. 提高代码质量

  • 更少的 Bug: 先写测试能更早发现问题
  • 更好的设计: TDD 促进模块化和低耦合设计
  • 更高的覆盖率: 确保每个功能都有测试

2. 提高开发效率

  • 快速反馈: 立即知道代码是否正确
  • 安全重构: 测试保护让重构更安全
  • 减少调试时间: 问题更容易定位

3. 更好的文档

  • 活文档: 测试用例就是最好的使用示例
  • 需求验证: 测试反映了真实的需求

TDD 实践步骤

步骤 1: 分析需求,编写测试列表

markdown
## 用户注册功能测试列表
- [ ] 有效邮箱和密码应该注册成功
- [ ] 重复邮箱应该注册失败
- [ ] 密码少于 8 位应该失败
- [ ] 邮箱格式错误应该失败
- [ ] 注册成功应该返回用户信息
- [ ] 注册成功应该发送欢迎邮件

步骤 2: 编写第一个失败的测试

Java 示例

java
@Test
public void shouldRegisterUserWithValidData() {
    // Given
    RegisterRequest request = new RegisterRequest(
        "user@example.com",
        "password123",
        "John Doe"
    );
    
    // When
    User user = userService.register(request);
    
    // Then
    assertNotNull(user);
    assertEquals("user@example.com", user.getEmail());
    assertEquals("John Doe", user.getName());
    assertTrue(user.getId() > 0);
}

Python 示例

python
def test_register_user_with_valid_data():
    # Given
    request = RegisterRequest(
        email="user@example.com",
        password="password123",
        name="John Doe"
    )
    
    # When
    user = user_service.register(request)
    
    # Then
    assert user is not None
    assert user.email == "user@example.com"
    assert user.name == "John Doe"
    assert user.id > 0

TypeScript 示例

typescript
describe('UserService', () => {
  it('should register user with valid data', async () => {
    // Given
    const request: RegisterRequest = {
      email: 'user@example.com',
      password: 'password123',
      name: 'John Doe'
    };
    
    // When
    const user = await userService.register(request);
    
    // Then
    expect(user).toBeDefined();
    expect(user.email).toBe('user@example.com');
    expect(user.name).toBe('John Doe');
    expect(user.id).toBeGreaterThan(0);
  });
});

步骤 3: 编写最小代码使测试通过

Java 实现

java
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    
    public User register(RegisterRequest request) {
        User user = new User();
        user.setEmail(request.getEmail());
        user.setName(request.getName());
        user.setPassword(hashPassword(request.getPassword()));
        
        return userRepository.save(user);
    }
}

步骤 4: 重构代码

java
@Service
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final EmailService emailService;
    
    // 构造函数注入,提高可测试性
    public UserService(UserRepository userRepository, 
                      PasswordEncoder passwordEncoder,
                      EmailService emailService) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.emailService = emailService;
    }
    
    public User register(RegisterRequest request) {
        // 验证邮箱是否已存在
        validateEmailNotExists(request.getEmail());
        
        // 创建用户
        User user = createUser(request);
        
        // 保存用户
        User savedUser = userRepository.save(user);
        
        // 发送欢迎邮件
        emailService.sendWelcomeEmail(savedUser);
        
        return savedUser;
    }
    
    private void validateEmailNotExists(String email) {
        if (userRepository.existsByEmail(email)) {
            throw new DuplicateEmailException("Email already exists");
        }
    }
    
    private User createUser(RegisterRequest request) {
        return User.builder()
            .email(request.getEmail())
            .name(request.getName())
            .password(passwordEncoder.encode(request.getPassword()))
            .createdAt(LocalDateTime.now())
            .build();
    }
}

步骤 5: 继续下一个测试

java
@Test
public void shouldFailWhenEmailAlreadyExists() {
    // Given
    String existingEmail = "existing@example.com";
    createExistingUser(existingEmail);
    
    RegisterRequest request = new RegisterRequest(
        existingEmail,
        "password123",
        "John Doe"
    );
    
    // When & Then
    assertThrows(DuplicateEmailException.class, () -> {
        userService.register(request);
    });
}

API 测试的 TDD 实践

1. 控制器层测试

java
@WebMvcTest(UserController.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Test
    void shouldReturnCreatedWhenRegisterSuccess() throws Exception {
        // Given
        RegisterRequest request = new RegisterRequest(
            "user@example.com",
            "password123",
            "John Doe"
        );
        
        User user = new User(1L, "user@example.com", "John Doe");
        when(userService.register(any())).thenReturn(user);
        
        // When & Then
        mockMvc.perform(post("/api/users/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.email").value("user@example.com"))
                .andExpect(jsonPath("$.name").value("John Doe"));
    }
}

2. 集成测试

java
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class UserIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void shouldCompleteRegistrationFlow() throws Exception {
        // Given
        String requestBody = """
            {
                "email": "newuser@example.com",
                "password": "securePassword123",
                "name": "New User"
            }
            """;
        
        // When & Then
        mockMvc.perform(post("/api/users/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").exists())
                .andExpect(jsonPath("$.email").value("newuser@example.com"));
        
        // Verify user exists in database
        mockMvc.perform(get("/api/users")
                .param("email", "newuser@example.com"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].email").value("newuser@example.com"));
    }
}

3. 契约测试

java
@ExtendWith(PactConsumerTestExt.class)
class UserApiContractTest {
    
    @Pact(consumer = "Frontend", provider = "UserService")
    public RequestResponsePact createPact(PactDslWithProvider builder) {
        return builder
            .given("No user with email exists")
            .uponReceiving("A request to register user")
                .path("/api/users/register")
                .method("POST")
                .headers("Content-Type", "application/json")
                .body(new PactDslJsonBody()
                    .stringType("email", "user@example.com")
                    .stringType("password", "password123")
                    .stringType("name", "John Doe"))
            .willRespondWith()
                .status(201)
                .headers(Map.of("Content-Type", "application/json"))
                .body(new PactDslJsonBody()
                    .integerType("id")
                    .stringType("email", "user@example.com")
                    .stringType("name", "John Doe"))
            .toPact();
    }
}

TDD 最佳实践

1. 测试命名规范

java
// 好的命名:说明测试场景和预期结果
@Test
void shouldReturnErrorWhenPasswordTooShort() { }

@Test
void shouldSendWelcomeEmailAfterSuccessfulRegistration() { }

// 避免:模糊不清的命名
@Test
void testRegister() { }

@Test
void test1() { }

2. AAA 模式(Arrange-Act-Assert)

java
@Test
void shouldCalculateTotalPrice() {
    // Arrange (Given) - 准备测试数据
    ShoppingCart cart = new ShoppingCart();
    cart.addItem(new Item("Book", 10.0, 2));
    cart.addItem(new Item("Pen", 2.0, 5));
    
    // Act (When) - 执行测试动作
    double total = cart.calculateTotal();
    
    // Assert (Then) - 验证结果
    assertEquals(30.0, total);
}

3. 测试隔离

java
class UserServiceTest {
    
    @Mock
    private UserRepository userRepository;
    
    @Mock
    private EmailService emailService;
    
    @InjectMocks
    private UserService userService;
    
    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
    }
    
    @Test
    void shouldNotDependOnExternalServices() {
        // 使用 Mock 隔离外部依赖
        when(userRepository.save(any())).thenReturn(new User());
        
        // 测试只关注 UserService 的逻辑
        userService.register(new RegisterRequest());
        
        // 验证交互
        verify(emailService).sendWelcomeEmail(any());
    }
}

4. 测试数据构建器

java
public class UserTestDataBuilder {
    private String email = "test@example.com";
    private String name = "Test User";
    private String password = "password123";
    
    public UserTestDataBuilder withEmail(String email) {
        this.email = email;
        return this;
    }
    
    public UserTestDataBuilder withName(String name) {
        this.name = name;
        return this;
    }
    
    public RegisterRequest buildRequest() {
        return new RegisterRequest(email, password, name);
    }
    
    public User buildUser() {
        return User.builder()
            .email(email)
            .name(name)
            .password(password)
            .build();
    }
}

// 使用示例
@Test
void shouldRegisterUser() {
    RegisterRequest request = new UserTestDataBuilder()
        .withEmail("custom@example.com")
        .buildRequest();
    
    // ...
}

TDD 与 CI/CD 集成

GitHub Actions 示例

yaml
name: TDD Pipeline

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up JDK
      uses: actions/setup-java@v3
      with:
        java-version: '17'
        
    - name: Run Tests
      run: ./mvnw test
      
    - name: Generate Test Report
      uses: dorny/test-reporter@v1
      if: success() || failure()
      with:
        name: Maven Tests
        path: target/surefire-reports/*.xml
        reporter: java-junit
        
    - name: Check Coverage
      run: ./mvnw jacoco:report
      
    - name: Upload Coverage
      uses: codecov/codecov-action@v3
      with:
        file: target/site/jacoco/jacoco.xml
        fail_ci_if_error: true
        minimum_coverage: 80

常见问题

Q: TDD 会降低开发速度吗?

A: 短期内可能会慢一些,但长期来看,由于减少了 bug 和调试时间,整体效率会提高。

Q: 所有代码都需要 TDD 吗?

A: 不是。核心业务逻辑必须 TDD,简单的 CRUD 操作可以适当放松要求。

Q: 如何处理遗留代码?

A: 先为要修改的部分编写测试,然后再进行修改(特征测试)。

Q: Mock 过多怎么办?

A: 过多的 Mock 可能意味着设计有问题,考虑重构以减少依赖。

总结

TDD 不仅是一种测试方法,更是一种设计方法。通过 TDD,我们能够:

  1. 写出更好的代码: 可测试的代码通常设计更好
  2. 减少 Bug: 及早发现问题
  3. 提高信心: 有测试保护,重构更安全
  4. 形成文档: 测试就是最好的使用示例

记住 TDD 的口诀:红灯-绿灯-重构,让测试驱动你的开发!

SOLO Development Guide