测试审查
这一课你将能做到
- 识别出「为了覆盖率而写」的形式主义测试,并给出改进方案
- 审查单元测试、集成测试的边界是否合理(什么该 mock,什么不该 mock)
- 用「变异测试思维」判断测试是否真的能抓住 bug
为什么要学
覆盖率 80% 不代表代码可靠——很多团队 80% 覆盖率的代码,线上事故一点不少。因为测试的价值不在「跑过」,而在「能不能抓住回归」。
人话:写测试就像给代码买保险。垃圾测试 = 买了一份「只保陨石砸中」的保险——保费交了,真出事一毛钱赔不了。
先建立图像
测试金字塔(本站推荐比例)
╱ E2E ╲ ← 极少(5%),跑核心流程
╱ 集成测试 ╲ ← 适量(25%),起真 MySQL/Redis
╱ 单元测试 ╲ ← 大量(70%),纯逻辑、毫秒级
审查关注点:
1. 每层该测什么、不该测什么
2. 断言是否真的验证了行为(而非只是「跑过不抛异常」)
3. 测试之间是否独立(顺序无关、无共享状态)
4. 测试失败时能否定位问题
跟着做
步骤 1:识别「假测试」
❌ 反例:无断言、或断言形同虚设
@Test
void testEnroll() {
courseService.enroll(1L, 100L);
// 没断言!只要不抛异常就算过
}
@Test
void testGetCourse() {
CourseVO vo = courseService.get(1L);
assertNotNull(vo); // 只断言非 null,字段对不对不管
}
@Test
void testListCourses() {
List<CourseVO> list = courseService.list();
System.out.println(list); // 用眼睛看?CI 里谁看?
}
✅ 正例:断言行为与状态
@Test
void enroll_shouldDecreaseQuota_whenQuotaAvailable() {
// given
Long courseId = 1L;
Long userId = 100L;
Course before = courseMapper.selectById(courseId);
int quotaBefore = before.getQuota();
// when
courseService.enroll(courseId, userId);
// then
Course after = courseMapper.selectById(courseId);
assertEquals(quotaBefore - 1, after.getQuota());
Enroll record = enrollMapper.selectOne(courseId, userId);
assertNotNull(record);
assertEquals(EnrollStatus.ACTIVE, record.getStatus());
}
@Test
void enroll_shouldThrow_whenQuotaExhausted() {
// given: 名额已满的课程
Long courseId = 999L;
// when & then
BizException ex = assertThrows(BizException.class,
() -> courseService.enroll(courseId, 100L));
assertEquals(ErrorCode.QUOTA_EXHAUSTED, ex.getCode());
}
步骤 2:审 mock 的边界
❌ 反例:过度 mock,测试变成「验证 mock 自己被调了」
@Test
void testGetCourse() {
CourseMapper mockMapper = mock(CourseMapper.class);
Course fakeCourse = new Course();
fakeCourse.setTitle("测试课");
when(mockMapper.selectById(1L)).thenReturn(fakeCourse);
CourseService service = new CourseService(mockMapper);
CourseVO result = service.get(1L);
assertEquals("测试课", result.getTitle());
verify(mockMapper).selectById(1L); // 验证 mock 被调?这不废话吗
}
这个测试能抓到什么 bug?什么都抓不到。它只验证了「mock 返回什么,结果就是什么」——永远绿。
✅ 正例:单元测试测纯逻辑(不带 DB),集成测试测真实 DB
// 单元测试:测业务规则计算,不碰 DB
@ExtendWith(MockitoExtension.class)
class PriceCalculatorTest {
@Test
void shouldApplyVipDiscount_whenUserIsVip() {
PriceCalculator calc = new PriceCalculator();
BigDecimal price = calc.calculate(new BigDecimal("100"), UserTier.VIP);
assertEquals(new BigDecimal("80.00"), price);
}
@Test
void shouldRejectNegativePrice() {
PriceCalculator calc = new PriceCalculator();
assertThrows(IllegalArgumentException.class,
() -> calc.calculate(new BigDecimal("-1"), UserTier.NORMAL));
}
}
// 集成测试:真实 MySQL(用 Testcontainers 或 H2 内存模式)
@SpringBootTest
@Transactional
class CourseServiceIT {
@Autowired CourseService courseService;
@Autowired CourseMapper courseMapper;
@Test
void enroll_shouldPreventDuplicate_whenConcurrent() throws Exception {
// 并发报名同一课程,验证唯一索引生效
int threadCount = 10;
CountDownLatch latch = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
for (int i = 0; i < threadCount; i++) {
long userId = i;
pool.submit(() -> {
try {
latch.await();
courseService.enroll(1L, userId);
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
}
});
}
latch.countDown();
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
// 名额只有 5 个
assertEquals(5, successCount.get());
assertEquals(5, failCount.get());
}
}
步骤 3:审测试的独立性
@Test
@Order(1)
void step1_createCourse() { ... }
@Test
@Order(2)
void step2_enrollCourse() {
// 依赖 step1 创建的数据
}
问题:单独跑 step2 会挂;并行跑会挂;CI 上时序抖动会挂。
@BeforeEach
void setUp() {
testCourse = courseMapper.insert(buildTestCourse());
}
@AfterEach
void tearDown() {
courseMapper.deleteById(testCourse.getId());
}
或者集成测试用 @Transactional 自动回滚。
步骤 4:审边界条件覆盖
□ null 输入
□ 空字符串 / 空集合
□ 0、-1、Integer.MAX_VALUE
□ 重复提交(幂等)
□ 并发(多线程同时调)
□ 超时 / 外部依赖挂掉
□ 权限边界(无权限用户调用)
□ 数据边界(刚创建、刚删除、软删除后)
@Test
void testTransfer() {
accountService.transfer(from, to, 100);
assertEquals(900, from.getBalance());
}
@Test void transfer_success() { ... }
@Test void transfer_shouldFail_whenBalanceInsufficient() { ... }
@Test void transfer_shouldFail_whenAmountIsZero() { ... }
@Test void transfer_shouldFail_whenAmountIsNegative() { ... }
@Test void transfer_shouldBeIdempotent_whenRetrying() { ... }
@Test void transfer_shouldRollback_whenTargetAccountNotFound() { ... }
步骤 5:审断言的失败信息
assertEquals(expected, actual);
// 失败时只看到 expected: <100> but was: <200>
// 100 是什么?200 是什么?
assertEquals(expected, actual,
() -> String.format("课程 %d 报名后名额扣减错误,courseId=%d, userId=%d",
courseId, courseId, userId));
assertThat(after.getQuota())
.as("课程 %d 报名后名额", courseId)
.isEqualTo(quotaBefore - 1);
讲清楚要点
- 覆盖率是必要不充分条件:没有覆盖率一定不行,但只有覆盖率也不行。关注「mutation score」(变异测试通过率)比关注「line coverage」更有意义。
- 单元测试测逻辑,集成测试测集成:纯计算、纯规则用单元测试(mock 掉 IO);涉及 DB/Redis/MQ 的用集成测试(起真实环境)。两者别混。
- 每个测试必须独立:顺序无关、可单独运行、可并行。共享状态是测试腐化的开始。
- 断言要针对行为而非实现:不要断言「内部方法被调了几次」,要断言「对外可观察的结果是什么」。重构内部实现时测试不该挂。
- 测试失败信息是排障起点:失败时能不能 5 秒内定位是哪个场景、什么数据、期望值是多少?不能就是断言没写好。
你可能会懵的地方
-
覆盖率要追到多少?
没有标准答案,但业内经验值:核心业务(支付、订单、库存)90%+;一般业务 70%+;工具类、简单 CRUD 可以更低。盲目追 100% 会导致大量「为覆盖而写」的废测试。
-
私有方法要不要直接测?
不要。私有方法是实现细节,通过公有方法间接测。如果私有方法复杂到必须直接测,说明应该抽成独立的类。
-
集成测试很慢怎么办?
分层跑:本地提交前只跑单元测试(秒级);CI 上跑全部(分钟级); nightly 跑全量 + 端到端。用 Testcontainers + 共享容器、Spring 的 @DirtiesContext 谨慎使用(会重启上下文拖慢)。
-
Mock 到底什么时候用?
外部不可控依赖(第三方支付、短信网关)必须 mock;数据库在单元测试里 mock、在集成测试里用真库;自己写的 Service 之间不要互相 mock(否则你在测 mock 框架)。
-
变异测试是什么?
工具(如 PIT)自动修改你的代码(比如把 > 改成 >=),然后跑测试。如果测试还绿,说明测试没抓住这个变异——测试质量有问题。mutation score 比 line coverage 更真实。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 测试无断言 | 永远不挂,等于没测 | 每个测试至少一个针对行为的断言 |
assertNotNull(vo) 就完事 | 字段错了也发现不了 | 断言关键字段值 |
| 所有测试都 mock DB | 集成 bug 全漏 | 单测 mock + 集测真库 |
| 测试间共享数据 | 顺序敏感、难调试 | 每个测试自建数据 + 事务回滚 |
| 只测 happy path | 边界 bug 漏网 | 按边界清单补全 |
卡点急救
-
CI 上测试偶发失败(flaky):先别急着重跑忽略。常见原因:依赖时间(用 Instant.now() 不注入 Clock)、依赖顺序、共享资源没清理、超时太短。修复优先级:flaky test 比没测试更糟糕,因为它训练团队忽略红灯。
-
老代码没法测(私有静态、new 依赖):不要硬写测试。先用「接缝」技术:抽接口、依赖注入、把静态方法包一层。《Working Effectively with Legacy Code》是圣经。
-
集成测试慢到不想跑:检查是不是 @SpringBootTest 启动了整个应用上下文。用 @WebMvcTest、@DataJpaTest 等切片测试只加载需要的层。
面试常问
-
测试金字塔是什么?为什么不要倒过来?
单元测试多、集成测试适量、E2E 测试少。因为越往上:运行越慢、定位越难、维护越贵、稳定性越差。倒金字塔(大量 E2E)会导致 CI 一次 1 小时、失败时不知道哪里错、稍微改 UI 就全挂。
-
TDD 你怎么看?
诚实回答:业界实践率不高,但 TDD 的核心价值不在「先写测试」这个动作,而在强迫你在写实现前想清楚接口和边界。可以不强求严格 TDD,但建议「接口设计 → 测试用例 → 实现 → 补全测试」的轻量节奏。
-
如何评估测试质量?
三层:① 覆盖率(行/分支)底线 70%+;② 变异测试通过率(PIT),看测试能不能抓住注入的 bug;③ 主观评审——断言是否针对行为、测试是否独立、失败信息是否清晰。
自测三问
- 为什么
verify(mockMapper).selectById(1L) 这种断言通常没价值?
- 单元测试和集成测试的分工是什么?各举一个本站模块的例子。
- 测试用
@Order 控制顺序有什么问题?
小结一句话
测试审查的核心是问「这个测试能抓住 bug 吗」,而不是「这个测试绿了吗」——覆盖率是底线,断言质量是上限。
延伸
- JUnit 5 官方文档
- Testcontainers
- 《Working Effectively with Legacy Code》(Michael Feathers)
- 《Unit Testing: Principles, Practices, and Patterns》(Vladimir Khorikov)
- 下一课:代码风格——Lint 与一致性。