为什么你的测试都是摆设——Java 工程师写不好测试的5个根本原因
为什么你的测试都是摆设——Java 工程师写不好测试的5个根本原因
适读人群:有一定 Java 开发经验、写过测试但效果不好的工程师 | 阅读时长:约12分钟 | 核心价值:找到自己测试写不好的真正原因,不再做无效努力
那个让我彻夜未眠的线上事故
2021年双十一前夕,凌晨两点,我们的订单系统突然开始大量报错。
错误信息很简单:NullPointerException at OrderService.calculateDiscount(OrderService.java:87)。
值班的同事打电话给我,我从睡梦中爬起来,登上服务器看日志。问题定位到了:calculateDiscount 方法里有一段逻辑,当用户的会员等级为 null 时,直接调用了 .getLevel() 方法,触发了空指针。
这段代码是三周前上线的。我拉出来看了看,心里一沉——这段逻辑在测试环境跑过,单元测试也写了。
但测试是这么写的:
@Test
void testCalculateDiscount() {
User user = new User();
user.setMemberLevel(MemberLevel.GOLD);
double discount = orderService.calculateDiscount(user, 100.0);
assertEquals(0.9, discount);
}测试通过了。但线上的用户,有相当一部分是"游客下单",memberLevel 根本没有初始化,是 null。
这个 case,测试完全没覆盖到。
我们花了四十分钟修复、发布、回滚流量。那天晚上,我坐在电脑前,把整个项目的测试代码翻了一遍。看完之后心里更凉了:200多个测试方法,90% 都是这种"只测正常路径"的摆设。
那之后我开始认真研究:Java 工程师到底为什么写不好测试?
根本原因一:只测"我写了什么",不测"会发生什么"
这是最普遍的错误。
大多数工程师写测试的思路是:我写了一个 calculateDiscount 方法,那我就测试一下 calculateDiscount 能不能跑通。结果写出来的测试,和业务逻辑几乎是一一对应的翻译版本。
正确的思路应该是:这个方法在什么边界条件下会出问题?
- 入参是
null会怎样? - 金额是 0 或负数会怎样?
- 会员等级枚举值新增了一个但没处理会怎样?
- 并发调用这个方法会有问题吗?
这些问题,才是测试真正要回答的。
踩坑实录:
现象: 测试全绿,上线报 NPE。
原因: 开发者测试时只传了自己"认为合理"的参数,没有考虑实际数据的多样性。
解法: 写测试之前先问自己三个问题:这个方法的调用者可能传什么奇葩数据?数据库里可能存着什么历史脏数据?第三方接口可能返回什么异常结构?然后把这些情况一一枚举出来写成测试用例。
@Test
void testCalculateDiscount_whenMemberLevelIsNull_shouldApplyNoDiscount() {
User user = new User();
user.setMemberLevel(null); // 关键:测试 null 情况
double discount = orderService.calculateDiscount(user, 100.0);
assertEquals(1.0, discount, "游客用户不享受折扣,折扣系数应为1.0");
}
@Test
void testCalculateDiscount_whenAmountIsZero_shouldReturnZero() {
User user = new User();
user.setMemberLevel(MemberLevel.GOLD);
double discount = orderService.calculateDiscount(user, 0.0);
assertEquals(0.0, discount, "金额为0时折后金额应为0");
}
@Test
void testCalculateDiscount_whenAmountIsNegative_shouldThrowException() {
User user = new User();
user.setMemberLevel(MemberLevel.GOLD);
assertThrows(IllegalArgumentException.class,
() -> orderService.calculateDiscount(user, -50.0),
"负数金额应抛出 IllegalArgumentException");
}根本原因二:测试和业务代码强耦合,改一行代码要改十个测试
我在某个项目里看到过这样的测试:
@Test
void testCreateOrder() {
// 测试里直接 mock 了 5 个内部依赖
when(userDao.findById(1L)).thenReturn(mockUser);
when(inventoryDao.checkStock(anyLong())).thenReturn(true);
when(discountService.getDiscount(any())).thenReturn(0.9);
when(paymentGateway.preAuth(any())).thenReturn("txn_001");
when(orderDao.save(any())).thenReturn(savedOrder);
Order result = orderService.createOrder(createOrderRequest);
// 验证了 6 个 verify
verify(userDao).findById(1L);
verify(inventoryDao).checkStock(anyLong());
// ...
}这个测试和实现细节完全绑定。一旦内部实现稍作重构——比如把 userDao.findById 改成走缓存——测试就报错了,哪怕业务行为完全没变。
踩坑实录:
现象: 重构代码时,测试大量报错,但手动验证功能是正常的。团队里有人开始说"测试影响开发效率"。
原因: 测试耦合了实现细节(哪个 DAO 被调用了几次),而不是验证业务行为(订单是否被正确创建)。
解法: 测试应该验证"输入→输出"的行为契约,而不是内部调用链。如果必须用 Mock,只 mock 外部依赖(数据库、HTTP调用),不 mock 内部同层服务。
@Test
void testCreateOrder_shouldReturnOrderWithCorrectAmount() {
// 只关心:给定一个合法请求,返回的订单金额是否正确
CreateOrderRequest request = CreateOrderRequest.builder()
.userId(1L)
.productId(100L)
.quantity(2)
.build();
Order order = orderService.createOrder(request);
assertNotNull(order.getOrderId());
assertEquals(OrderStatus.PENDING, order.getStatus());
assertTrue(order.getTotalAmount().compareTo(BigDecimal.ZERO) > 0);
}根本原因三:测试数据硬编码,环境一换就失效
这个问题在我接手过的几乎每个项目里都见到过:
@Test
void testGetUserInfo() {
// userId=1 是测试库里的一条固定数据
UserInfo info = userService.getUserInfo(1L);
assertEquals("张三", info.getName());
assertEquals("138xxxx1234", info.getPhone());
}这个测试,在本地开发环境跑得好好的。CI 环境一跑,数据库是空的,直接失败。或者 userId=1 的记录被别人改了,名字不再是"张三",测试也失败。
踩坑实录:
现象: CI 流水线上测试时好时坏,本地永远是绿的。团队开始习惯性地忽略 CI 失败,觉得"可能是环境问题"。
原因: 测试依赖外部共享状态(共享数据库、共享缓存),而外部状态是不可控的。
解法: 每个测试负责自己的数据准备(Arrange)和清理(Teardown)。用 @BeforeEach 插入数据,用 @AfterEach 清理,或者用 H2 内存数据库做数据库测试。
@SpringBootTest
@Transactional // 每个测试后自动回滚
class UserServiceTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
private Long testUserId;
@BeforeEach
void setUp() {
// 每个测试用例自己创建数据
User user = new User();
user.setName("测试用户");
user.setPhone("13800000000");
user.setStatus(UserStatus.ACTIVE);
testUserId = userRepository.save(user).getId();
}
@Test
void testGetUserInfo_shouldReturnCorrectData() {
UserInfo info = userService.getUserInfo(testUserId);
assertEquals("测试用户", info.getName());
assertEquals("13800000000", info.getPhone());
}
}根本原因四:根本不知道"测试什么、不测什么"
这个问题比较隐性,但杀伤力极强。
很多工程师写测试是"凭感觉"——感觉这个方法复杂,就多写几个;感觉那个 getter/setter 简单,就不写。但"感觉"是靠不住的。
我见过有人给 getUsername() 写了三个测试,但给核心的权限校验逻辑一个测试都没写。
正确的测试优先级应该是:
- 业务核心逻辑:金融计算、权限校验、状态流转——必须测,且要测边界。
- 容易出错的复杂条件:多层 if-else、复杂的日期/时间计算——重点测。
- 外部依赖的集成点:数据库操作、HTTP 调用、消息队列——做集成测试。
- CRUD 的基础路径:简单的增删改查——轻量覆盖即可。
- 框架生成的代码:Lombok 的
@Data、MyBatis 的基础 CRUD——不用测。
踩坑实录:
现象: 覆盖率报告显示 75%,但核心业务出了 bug。
原因: 覆盖率是被简单的 getter/setter 测试"刷"上去的,真正复杂的业务逻辑覆盖率很低。
解法: JaCoCo 配合代码复杂度分析,对圈复杂度(Cyclomatic Complexity)高于 5 的方法强制要求测试覆盖。同时,代码审查时要检查测试质量,不只看数量。
根本原因五:没有把测试当成"设计工具",只当"验证工具"
这是最深层的认知问题。
大多数工程师的开发顺序是:写代码 → 写测试 → 测试通过 → 提交。测试是事后补的。
这种模式下,测试永远是被动的。你已经把代码写死了,测试只能顺着你的实现走,很难发现设计上的问题。
真正有价值的测试,是在写代码之前就想清楚:
- 这个方法的调用接口是什么?
- 它应该接受什么输入,返回什么输出?
- 有哪些异常场景需要明确处理?
如果你先写测试,你会发现很多设计问题:方法签名太复杂(参数太多,说明职责不清)、返回值不明确(返回 Object 导致调用方无法确定类型)、依赖太多(需要 mock 七八个对象,说明这个类承担了太多职责)。
踩坑实录:
现象: 一个 Service 方法有 12 个参数,测试写起来极其痛苦,构造测试数据要写 50 行。
原因: 方法设计本身有问题,但因为是"事后补测试",开发者勉强把测试写出来了,掩盖了设计问题。
解法: 至少在写核心业务逻辑时,先用测试描述行为,再写实现。哪怕不完全 TDD,这个思维方式能让你的设计更清晰。
// 先写测试,描述你期望的行为
@Test
void testTransferMoney_whenBalanceInsufficient_shouldThrowInsufficientBalanceException() {
// Arrange
Account from = Account.of("ACC001", BigDecimal.valueOf(100));
Account to = Account.of("ACC002", BigDecimal.valueOf(0));
// Act & Assert
InsufficientBalanceException ex = assertThrows(
InsufficientBalanceException.class,
() -> transferService.transfer(from, to, BigDecimal.valueOf(200))
);
assertEquals("ACC001", ex.getAccountId());
assertEquals(BigDecimal.valueOf(100), ex.getCurrentBalance());
assertEquals(BigDecimal.valueOf(200), ex.getRequiredAmount());
}
// 然后再去写 TransferService.transfer() 的实现
// 你会发现,InsufficientBalanceException 需要携带这三个字段
// 这个需求,不先写测试你不一定能想到一个快速自检清单
如果你想评估自己的测试质量,用这个清单:
方法层面:
测试设计层面:
项目层面:
写在最后
我有一个判断测试是否有效的简单标准:这个测试,能不能在上线前抓住那次让你彻夜爬起来的 bug?
如果不能,那它就是摆设。
写测试不是为了完成 KPI 里的"代码覆盖率"指标,不是为了应付代码审查。写测试是为了让你下班后手机不响,是为了让你重构代码时不担惊受怕,是为了让接手你代码的人不骂你。
接下来的系列文章,我会把这几个根本原因逐一展开,配合 JUnit 5、Mockito、Spring Boot Test 等工具,给出完整的实战方案。
