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 > 0TypeScript 示例
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,我们能够:
- 写出更好的代码: 可测试的代码通常设计更好
- 减少 Bug: 及早发现问题
- 提高信心: 有测试保护,重构更安全
- 形成文档: 测试就是最好的使用示例
记住 TDD 的口诀:红灯-绿灯-重构,让测试驱动你的开发!