CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
代码审查入门/课时/测试审查
RawBlame
课时目录12 节

代码审查入门

  1. 01API 语义审查
  2. 02实现语义
  3. 03文档审查
  4. 04测试审查
  5. 05代码风格
  6. 06Review 流程
  7. 07审查清单
  8. 08安全审查
  9. 09AI 辅助审查
  10. 10反馈技巧
  11. 11团队实践
  12. 12综合练习

测试审查

断言与覆盖质量。

30 分钟

测试审查

这一课你将能做到

  1. 识别出「为了覆盖率而写」的形式主义测试,并给出改进方案
  2. 审查单元测试、集成测试的边界是否合理(什么该 mock,什么不该 mock)
  3. 用「变异测试思维」判断测试是否真的能抓住 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
□ 重复提交(幂等)
□ 并发(多线程同时调)
□ 超时 / 外部依赖挂掉
□ 权限边界(无权限用户调用)
□ 数据边界(刚创建、刚删除、软删除后)

❌ 反例:只测 happy path

@Test
void testTransfer() {
    accountService.transfer(from, to, 100);
    assertEquals(900, from.getBalance());
}

✅ 正例:happy path + 关键边界

@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));

或用 AssertJ(可读性更好):

assertThat(after.getQuota())
        .as("课程 %d 报名后名额", courseId)
        .isEqualTo(quotaBefore - 1);

讲清楚要点

  1. 覆盖率是必要不充分条件:没有覆盖率一定不行,但只有覆盖率也不行。关注「mutation score」(变异测试通过率)比关注「line coverage」更有意义。
  2. 单元测试测逻辑,集成测试测集成:纯计算、纯规则用单元测试(mock 掉 IO);涉及 DB/Redis/MQ 的用集成测试(起真实环境)。两者别混。
  3. 每个测试必须独立:顺序无关、可单独运行、可并行。共享状态是测试腐化的开始。
  4. 断言要针对行为而非实现:不要断言「内部方法被调了几次」,要断言「对外可观察的结果是什么」。重构内部实现时测试不该挂。
  5. 测试失败信息是排障起点:失败时能不能 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 漏网按边界清单补全

卡点急救

  1. CI 上测试偶发失败(flaky):先别急着重跑忽略。常见原因:依赖时间(用 Instant.now() 不注入 Clock)、依赖顺序、共享资源没清理、超时太短。修复优先级:flaky test 比没测试更糟糕,因为它训练团队忽略红灯。

  2. 老代码没法测(私有静态、new 依赖):不要硬写测试。先用「接缝」技术:抽接口、依赖注入、把静态方法包一层。《Working Effectively with Legacy Code》是圣经。

  3. 集成测试慢到不想跑:检查是不是 @SpringBootTest 启动了整个应用上下文。用 @WebMvcTest、@DataJpaTest 等切片测试只加载需要的层。

面试常问

  • 测试金字塔是什么?为什么不要倒过来? 单元测试多、集成测试适量、E2E 测试少。因为越往上:运行越慢、定位越难、维护越贵、稳定性越差。倒金字塔(大量 E2E)会导致 CI 一次 1 小时、失败时不知道哪里错、稍微改 UI 就全挂。

  • TDD 你怎么看? 诚实回答:业界实践率不高,但 TDD 的核心价值不在「先写测试」这个动作,而在强迫你在写实现前想清楚接口和边界。可以不强求严格 TDD,但建议「接口设计 → 测试用例 → 实现 → 补全测试」的轻量节奏。

  • 如何评估测试质量? 三层:① 覆盖率(行/分支)底线 70%+;② 变异测试通过率(PIT),看测试能不能抓住注入的 bug;③ 主观评审——断言是否针对行为、测试是否独立、失败信息是否清晰。

自测三问

  1. 为什么 verify(mockMapper).selectById(1L) 这种断言通常没价值?
  2. 单元测试和集成测试的分工是什么?各举一个本站模块的例子。
  3. 测试用 @Order 控制顺序有什么问题?

小结一句话

测试审查的核心是问「这个测试能抓住 bug 吗」,而不是「这个测试绿了吗」——覆盖率是底线,断言质量是上限。

延伸

  • JUnit 5 官方文档
  • Testcontainers
  • 《Working Effectively with Legacy Code》(Michael Feathers)
  • 《Unit Testing: Principles, Practices, and Patterns》(Vladimir Khorikov)
  • 下一课:代码风格——Lint 与一致性。
加载本节练习…
← 上一课文档审查下一课 →代码风格