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

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

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

学习

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

更多

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

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
软件测试入门/课时/JUnit 5 基础
RawBlame
课时目录12 节

软件测试入门

  1. 01测试金字塔
  2. 02JUnit 5 基础
  3. 03Mockito 与 Mock
  4. 04Spring Boot 测试
  5. 05MockMvc 接口测试
  6. 06集成测试
  7. 07TDD 与可维护性
  8. 08前端单元测试
  9. 09E2E 与 Playwright
  10. 10覆盖率与质量门禁
  11. 11CI 中的测试
  12. 12综合练习

JUnit 5 基础

@Test、断言、@BeforeEach、参数化。

35 分钟

JUnit 5 基础

这一课你将能做到

  1. 写出带 @Test 与 AssertJ assertThat 的可运行单元测试类,并读懂失败报告
  2. 用 @BeforeEach 准备独立 fixture,保证用例互不污染、顺序无关
  3. 用 @ParameterizedTest + @CsvSource / @NullAndEmptySource 覆盖多组边界,并用 @TempDir 处理文件类测试

为什么要学

Spring Boot 3.5 的 spring-boot-starter-test 已内置 JUnit 5(Jupiter)+ AssertJ + Mockito,CodeForge 的 SubmissionServiceTest、RoadmapServiceTest 都跑在这套运行器上。人话:JUnit 是「如何发现并执行测试方法」的框架,断言负责告诉你「期望与实际差在哪」。

不会写 @Test,后面的 Mockito、MockMvc、CI 全都没落脚点。本课只练纯逻辑 / 轻量类的骨架;涉及外部依赖时交给下一课的 Mock。本课示例用判题域的两个小练习类(VerdictTranslator、SubmissionAudit)和一个文件导出练习,全部可直接运行,练的是你日后给 AuthService、SubmissionService 写测试时每天重复的肌肉动作。

先建立图像

JUnit 发现 @Test → 每个方法独立跑一遍
    │
    ├─ @BeforeEach:每个用例前重建「干净舞台」
    ├─ 测试体:Arrange(准备)→ Act(调用)→ Assert(断言)
    └─ @AfterEach(可选):清理资源

失败时:Surefire / IDE 显示「期望 vs 实际」与堆栈
概念人话常见写法
@Test「这是一个可执行用例」void 方法名()
断言「结果必须长这样」AssertJ assertThat(...)
Fixture「每个用例的干净舞台」@BeforeEach
参数化「同一规则,多组数据」@ParameterizedTest
@TempDir「自动建、自动删的临时目录」注入 Path 字段或参数

优先用 AssertJ(assertThat)而不是 JUnit 自带 assertEquals:失败信息更可读,链式断言更贴业务表达。

跟着做

步骤 1:写第一个测试类

新建练习类 VerdictTranslator.java(把判题状态翻成用户能看懂的文案):

public class VerdictTranslator {
    public static String translate(String status) {
        if (status == null) {
            return "未知";
        }
        return switch (status) {
            case "pending" -> "排队中";
            case "accepted" -> "通过";
            case "wrong_answer" -> "答案错误";
            case "time_limit_exceeded" -> "超时";
            default -> "未知";
        };
    }
}

注意这里用了 Java 17 的 switch 表达式——测试课同时帮你巩固新语法。测试:

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class VerdictTranslatorTest {

    @Test
    void pending翻译为排队中() {
        // Arrange 已由静态方法承担;Act + Assert
        assertThat(VerdictTranslator.translate("pending")).isEqualTo("排队中");
    }

    @Test
    void 未知状态兜底为未知() {
        assertThat(VerdictTranslator.translate("hacked")).isEqualTo("未知");
    }
}

步骤 2:定向运行,然后故意写红

cd backend
mvn test -Dtest=VerdictTranslatorTest

应看到两个用例绿。现在故意把期望改成 "错的期望" 再跑:

应看到 Surefire 报告里的 diff:expected 但 was。故意写红是为了认识失败长什么样——以后修 bug 你会反复读这段。改回正确期望,确认再绿。

步骤 3:用 @BeforeEach 保证独立

假设有这样一个练习类(内部可变状态):

import java.util.ArrayList;
import java.util.List;

public class SubmissionAudit {
    private final List<String> events = new ArrayList<>();

    public void record(String event) {
        events.add(event);
    }

    public List<String> events() {
        return List.copyOf(events);
    }
}

测试必须每个用例都拿到全新对象:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class SubmissionAuditTest {

    SubmissionAudit audit;

    @BeforeEach
    void setUp() {
        // 每个 @Test 前重建,避免用例间脏状态
        audit = new SubmissionAudit();
    }

    @Test
    void 记录一条事件() {
        audit.record("submitted");
        assertThat(audit.events()).containsExactly("submitted");
    }

    @Test
    void 多条事件保持顺序() {
        audit.record("submitted");
        audit.record("judged");
        assertThat(audit.events()).containsExactly("submitted", "judged");
    }
}

把两个用例顺序颠倒也应全绿。若把 audit 换成共享静态字段且不重置,就会出现「单跑绿、全跑红」的经典初学者坑。

步骤 4:参数化覆盖多边界

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;

class VerdictTranslatorParamTest {

    @ParameterizedTest
    @CsvSource({
        "pending, 排队中",
        "accepted, 通过",
        "wrong_answer, 答案错误",
        "time_limit_exceeded, 超时",
        "whatever, 未知"
    })
    void 状态映射(String status, String expected) {
        assertThat(VerdictTranslator.translate(status)).isEqualTo(expected);
    }

    @ParameterizedTest
    @NullAndEmptySource
    void 空输入兜底为未知(String status) {
        assertThat(VerdictTranslator.translate(status)).isEqualTo("未知");
    }
}

一组方法覆盖多组数据,报告里每个参数行各计一次执行。新增边界 = 加一行 CSV,而不是复制方法。

步骤 5:用 @TempDir 测文件逻辑(JUnit 5.9+)

测「导出判题记录/代码」这类文件操作,最怕临时文件污染磁盘。@TempDir 自动建目录、用完自动删:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class CodeExporter {
    private final Path baseDir;

    public CodeExporter(Path baseDir) {
        this.baseDir = baseDir;
    }

    public Path export(String fileName, String code) throws IOException {
        Path target = baseDir.resolve(fileName);
        Files.writeString(target, code);
        return target;
    }
}
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.*;

class CodeExporterTest {

    @TempDir
    Path tempDir;   // JUnit 5.9+:支持字段注入,每个用例独立目录

    @Test
    void 导出代码到临时目录() throws Exception {
        CodeExporter exporter = new CodeExporter(tempDir);

        Path file = exporter.export("Main.java", "public class Main {}");

        assertThat(file).exists();
        assertThat(file).content().contains("public class Main");
    }
}

人话:临时目录的生命周期交给框架,你只写业务断言。

步骤 6:对齐 CI 命令

mvn test          # 日常快反馈
mvn -B verify     # 提交前,与 .github/workflows/ci.yml 一致

本地与 CI 同命令,是后面所有课的前提。

讲清楚要点

  1. 每个 @Test 必须可独立重复:共享可变状态放 @BeforeEach 重建——顺序依赖是 flaky 之源。
  2. 失败信息是给人看的:优先 AssertJ 与行为化命名(pending翻译为排队中),test1 在 CI 红灯里毫无价值。
  3. 边界比 happy path 更易漏:null、空串、非法值、临界长度(6 与 5、64 与 65)要进用例或参数表。
  4. 测公开行为,不测私有实现:通过公有方法驱动私有逻辑,重构内部实现时测试不该红。
  5. 参数化适合「同规则多数据」:@CsvSource 是文档化的输入输出表;复杂对象可用 @MethodSource。
  6. 先定向后全量:-Dtest=类名 缩小反馈圈,verify 才是提交前的完整校验。

你可能会懵的地方

  • JUnit 4 和 5 混用怎么办? Boot 3.x 默认 Jupiter,注解来自 org.junit.jupiter.api。看到 org.junit.Test(无 jupiter)是旧包,新代码一律 Jupiter;旧用例按需迁移,不必一次改完。

  • 为什么推荐 AssertJ 而不是 assertEquals? 集合、字符串失败时 AssertJ 展示更清晰;链式写 assertThat(list).hasSize(2).first().isNotNull() 更贴业务;assertThat(file).content() 这类扩展 JUnit 原生断言没有。

  • 测试方法要 public 吗? Jupiter 不强制,包可见即可;返回类型必须是 void。方法名允许中文与下划线,重点是「失败时人能读懂」。

  • @BeforeAll 和 @BeforeEach 差在哪? BeforeAll 整个类跑一次且默认要求 static 方法(适合建昂贵共享资源);BeforeEach 每用例一次。入门默认用 BeforeEach 最安全。

  • @Disabled 和 @Tag 什么时候用? @Disabled 挂「已知坏但暂不修」的用例必须写原因;@Tag("integration") 用于把慢测分组(第 6 课展开),别滥用成「跳过测试」的借口。

错法对照

错法问题正解
用例共享可变静态字段顺序敏感、偶发红@BeforeEach 重建数据
方法名 test1 / t失败不可读用行为描述命名
只测成功路径边界回归漏补 null / 空 / 临界值
断言整个 toString易碎、难 diff断言关键字段
手写临时文件又不清理污染工作区@TempDir 托管生命周期

卡点急救

  1. Tests run: 0:类名没按 *Test 结尾,或导入了旧 JUnit 4 的 @Test;检查 import 是否为 org.junit.jupiter.api.Test。
  2. 中文方法名在报告里乱码:Maven 编码问题,给 maven-surefire-plugin 与 maven-compiler-plugin 都配 -Dfile.encoding=UTF-8(或 project.build.sourceEncoding)。
  3. @TempDir 字段始终 null:JUnit 版本低于 5.9 不支持字段注入;升级 Boot 管理的 JUnit 版本,或改用方法参数注入。

面试常问

  • @BeforeEach 与 @BeforeAll 的区别与选用? BeforeEach 每个用例前执行,保证隔离,最常用;BeforeAll 类级一次、默认需 static,适合建昂贵共享资源(容器、连接池)。隔离优先时选前者,成本敏感且只读时选后者。

  • 参数化测试的价值是什么? 把「同一规则 × 多组数据」收成一张表:减少复制粘贴、每行数据在报告里独立计数、新增边界只加一行。配合 @NullAndEmptySource 可把 null/空覆盖变成习惯。

  • 怎么保证测试之间互不影响? 三件事:fixture 在 @BeforeEach 重建;不依赖执行顺序(JUnit 默认顺序不确定);不共享可变静态状态。CodeForge 的 SubmissionAudit 练习就是反例教材——静态累加列表会让两个用例互相污染。

自测三问

  1. Arrange-Act-Assert 三步各自做什么?
  2. 什么时候用 @ParameterizedTest 比复制三个 @Test 更合适?
  3. @TempDir 解决了文件类测试的什么痛点?

小结一句话

@Test + assertThat + 独立 setUp = 单元测试骨架;有外部依赖再用 Mockito 替身。

延伸

  • JUnit 5 User Guide
  • AssertJ 官方文档
  • 下一课:test-mockito 用 Mockito 隔离 Mapper 与静态工具,给 Service 层写替身测试。
加载本节练习…
← 上一课测试金字塔下一课 →Mockito 与 Mock