Java 测试最佳实践总结——10条让测试代码变得真正有价值的原则
Java 测试最佳实践总结——10条让测试代码变得真正有价值的原则
适读人群:写了不少测试但总觉得不够好的工程师,想系统梳理测试开发规范的 Tech Lead | 阅读时长:约15分钟 | 核心价值:20篇系列文章的精华总结,形成可落地的测试规范
写在最前面
这是「自动化测试系列」的最后一篇,也是最重要的一篇。
前面 19 篇,我们从 JUnit 5 讲到 Mockito,从 Spring Boot Test 讲到并发测试,从 TDD 讲到遗留代码重构。覆盖了很多具体工具和技术点。
但技术细节学了很多,真正能沉淀下来成为团队开发规范的,往往是那些"原则",而不是具体的 API 用法。
这篇文章,我把整个系列里反复强调的核心原则提炼成 10 条,每条都配上正反例。这些原则,直接可以放进团队的技术规范文档里。
原则一:测试名称必须描述场景和预期结果
禁止:
@Test
void testCalculate() { ... }
@Test
void test1() { ... }
@Test
void calculateDiscountTest() { ... }要求:methodName_whenCondition_shouldExpectedResult 格式
@Test
void calculateDiscount_whenUserIsVip_shouldApplyTenPercentOff() { ... }
@Test
void createOrder_whenProductOutOfStock_shouldThrowOutOfStockException() { ... }
@Test
void transfer_whenAmountExceedsBalance_shouldThrowInsufficientBalanceException() { ... }理由:测试名称是代码的文档。好的名称让人一眼就能看懂测试在测什么,测试失败时也能快速定位问题域。
原则二:每个测试只断言一件事
禁止:
@Test
void testCreateUser() {
User user = userService.create("zhangsan", "password", "zhangsan@test.com");
assertNotNull(user.getId());
assertEquals("zhangsan", user.getName());
assertEquals("zhangsan@test.com", user.getEmail());
// 密码被加密了
assertNotEquals("password", user.getPasswordHash());
assertTrue(user.getPasswordHash().startsWith("$2a$")); // BCrypt
// 状态是激活的
assertEquals(UserStatus.ACTIVE, user.getStatus());
// 注册时间不为空
assertNotNull(user.getCreateTime());
}推荐(用 assertAll 聚合):
@Test
void createUser_withValidInput_shouldPersistAllFields() {
User user = userService.create("zhangsan", "password", "zhangsan@test.com");
assertAll("创建用户后,所有字段应该正确保存",
() -> assertNotNull(user.getId(), "用户ID应该被自动分配"),
() -> assertEquals("zhangsan", user.getName(), "用户名应该保存"),
() -> assertEquals("zhangsan@test.com", user.getEmail(), "邮箱应该保存"),
() -> assertNotEquals("password", user.getPasswordHash(), "密码应该被加密"),
() -> assertEquals(UserStatus.ACTIVE, user.getStatus(), "新用户默认激活状态"),
() -> assertNotNull(user.getCreateTime(), "创建时间应该自动填充")
);
}或者直接拆成多个测试:
@Test
void createUser_shouldEncryptPassword() {
User user = userService.create("zhangsan", "password", "zhangsan@test.com");
assertNotEquals("password", user.getPasswordHash());
assertTrue(BCrypt.checkpw("password", user.getPasswordHash()));
}
@Test
void createUser_shouldSetActiveStatus() {
User user = userService.create("zhangsan", "password", "zhangsan@test.com");
assertEquals(UserStatus.ACTIVE, user.getStatus());
}原则三:测试必须完全独立,可以任意顺序运行
禁止:
class OrderFlowTest {
private static Long orderId; // 静态变量在测试间传递状态
@Test
void test1_createOrder() {
orderId = orderService.create(request).getId(); // 设置给 test2 用
}
@Test
void test2_payOrder() {
orderService.pay(orderId, token); // 依赖 test1 运行过
}
}要求:每个测试自己准备数据,自己清理
class OrderPaymentTest {
@BeforeEach
void setUp() {
// 每个测试独立准备数据
this.testOrder = orderRepository.save(Order.builder()...build());
}
@Test
void payOrder_withValidToken_shouldSucceed() {
orderService.pay(testOrder.getId(), validToken);
assertEquals(OrderStatus.PAID, orderRepository.findById(testOrder.getId()).get().getStatus());
}
}原则四:测试数据用 Builder/ObjectMother,不要硬编码大量字段
禁止:
Order order = new Order();
order.setId(1L);
order.setUserId(100L);
order.setStatus(OrderStatus.PENDING);
order.setAmount(new BigDecimal("100"));
order.setCreateTime(LocalDateTime.now());
// ... 10 行赋值要求:测试专用 Builder,只设置测试关心的字段
// 只设置这个测试关心的字段
Order order = anOrder()
.withStatus(OrderStatus.PENDING)
.withAmount(new BigDecimal("100"))
.build();
// 或使用 ObjectMother
Order order = OrderMother.pendingOrder(); // 业务语义明确原则五:不要 Mock 你不拥有的东西的内部实现
禁止:
// Mock 了 JDK 的 LocalDateTime.now()
try (MockedStatic<LocalDateTime> mockedTime = mockStatic(LocalDateTime.class)) {
mockedTime.when(LocalDateTime::now).thenReturn(fixedTime);
// ...
}要求:把时间/随机数等注入进来,通过接口 Mock
// 把时钟抽象成接口
public interface Clock {
LocalDateTime now();
}
// 生产实现
@Component
public class SystemClock implements Clock {
public LocalDateTime now() { return LocalDateTime.now(); }
}
// 测试实现
public class FixedClock implements Clock {
private final LocalDateTime fixedTime;
public FixedClock(LocalDateTime time) { this.fixedTime = time; }
public LocalDateTime now() { return fixedTime; }
}
// 测试里注入 FixedClock,不需要 mockStatic
@Test
void testOrderExpiry() {
Clock clock = new FixedClock(LocalDateTime.of(2024, 6, 1, 12, 0));
OrderService service = new OrderService(clock, orderRepo);
// ...
}原则六:验证行为,不验证实现细节
禁止(过度验证内部调用):
@Test
void testGetUser() {
when(userRepo.findById(1L)).thenReturn(Optional.of(mockUser));
userService.getUser(1L);
verify(userRepo).findById(1L); // 多余!已经通过返回值验证了查询结果
verify(cacheManager).get("user:1"); // 多余!缓存是实现细节
}要求:验证输入→输出的行为,而不是谁调用了谁
@Test
void getUser_withValidId_shouldReturnUser() {
when(userRepo.findById(1L)).thenReturn(Optional.of(new User(1L, "张三")));
User result = userService.getUser(1L);
// 验证结果,不验证调用链
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals("张三", result.getName());
}原则七:异常测试必须验证异常内容,不只验证类型
禁止:
@Test
void testTransfer_insufficientBalance() {
assertThrows(InsufficientBalanceException.class,
() -> transferService.transfer(from, to, amount));
// 只验证了类型,没验证具体信息
}要求:验证异常类型 + 错误码 + 关键信息
@Test
void transfer_whenBalanceTooLow_shouldThrowWithAccountInfo() {
InsufficientBalanceException ex = assertThrows(
InsufficientBalanceException.class,
() -> transferService.transfer(from, to, new BigDecimal("500"))
);
assertEquals("INSUFFICIENT_BALANCE", ex.getErrorCode());
assertEquals(fromAccount.getId(), ex.getAccountId());
assertEquals(0, new BigDecimal("100").compareTo(ex.getCurrentBalance()));
assertEquals(0, new BigDecimal("500").compareTo(ex.getRequiredAmount()));
}原则八:集成测试使用 @Transactional 自动回滚,不要手动删数据
禁止:
@Test
void testCreateUser() {
userService.create(request);
// 手动清理测试数据
userRepository.deleteAll(); // 危险!可能删掉其他测试的数据
}要求:配合 @Transactional 自动回滚
@SpringBootTest
@Transactional // 测试后自动回滚,不需要手动清理
class UserServiceIntegrationTest {
@Test
void createUser_shouldPersistToDatabase() {
userService.create(request);
// 测试结束后,Spring 自动回滚事务,数据库恢复干净
}
}原则九:测试的三段式结构要清晰(Arrange-Act-Assert)
禁止(混乱结构):
@Test
void testOrder() {
when(repo.findById(1L)).thenReturn(Optional.of(order));
Order result = orderService.get(1L);
when(repo.findById(2L)).thenReturn(Optional.empty());
assertNotNull(result);
assertThrows(OrderNotFoundException.class, () -> orderService.get(2L));
assertEquals("张三", result.getUserName());
}要求:一个测试一个场景,三段式清晰
@Test
void getOrder_withValidId_shouldReturnOrder() {
// Arrange
Order mockOrder = Order.builder().id(1L).userName("张三").build();
when(orderRepo.findById(1L)).thenReturn(Optional.of(mockOrder));
// Act
Order result = orderService.get(1L);
// Assert
assertNotNull(result);
assertEquals("张三", result.getUserName());
}
@Test
void getOrder_withInvalidId_shouldThrowNotFoundException() {
// Arrange
when(orderRepo.findById(999L)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(OrderNotFoundException.class, () -> orderService.get(999L));
}原则十:测试代码也是生产代码,同等对待
禁止:
@Test
void test() { // 名称随意
// 200 行代码,没有注释
// 重复的逻辑没有提取
// 魔法数字满天飞
assertEquals(0.9, calc(u, 100, 0, 0, 0, 0)); // 0.9 是什么意思?
}要求:
- 测试代码同样需要 Code Review
- 公共逻辑提取到辅助方法或基类
- 魔法数字用有意义的变量名
- 测试类的命名规范与生产代码一致
// 提取有意义的常量
private static final BigDecimal VIP_DISCOUNT_RATE = new BigDecimal("0.9");
private static final BigDecimal STANDARD_AMOUNT = new BigDecimal("100");
@Test
void calculatePrice_forVipUser_shouldApplyVipDiscount() {
User vipUser = UserMother.vipUser();
BigDecimal finalPrice = priceCalculator.calculate(vipUser, STANDARD_AMOUNT);
BigDecimal expectedPrice = STANDARD_AMOUNT.multiply(VIP_DISCOUNT_RATE);
assertEquals(expectedPrice, finalPrice);
}完整的测试规范检查清单
将以上原则整理成 Checklist,在 Code Review 时逐项检查:
命名与结构:
独立性与隔离:
断言质量:
Mock 使用:
测试数据:
写在最后
整个自动化测试系列到这里就结束了。
如果我只能给你一个建议,那就是:从今天起,在你提交的每一行业务代码旁边,都留下一行测试代码。
不需要追求完美,不需要覆盖所有场景。只需要从最核心的业务逻辑开始,一行一行地把测试建立起来。
三个月后,当你做了一次较大的重构,却发现所有测试都通过了;当你的同事接手你的代码,看到完整的测试,说了一句"这个代码好改多了";当 CI 流水线挡住了一次潜在的线上事故——那一刻,你会明白这件事是值得的。
测试不是负担,是工程师对自己代码的尊重。
