CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
JUnit 5 基础
这一课你将能做到
写出带 @Test 与 AssertJ assertThat 的可运行单元测试类,并读懂失败报告
用 @BeforeEach 准备独立 fixture,保证用例互不污染、顺序无关
用 @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 一致
讲清楚要点
每个 @Test 必须可独立重复 :共享可变状态放 @BeforeEach 重建——顺序依赖是 flaky 之源。
失败信息是给人看的 :优先 AssertJ 与行为化命名(pending翻译为排队中),test1 在 CI 红灯里毫无价值。
边界比 happy path 更易漏 :null、空串、非法值、临界长度(6 与 5、64 与 65)要进用例或参数表。
测公开行为,不测私有实现 :通过公有方法驱动私有逻辑,重构内部实现时测试不该红。
参数化适合「同规则多数据」 :@CsvSource 是文档化的输入输出表;复杂对象可用 @MethodSource。
先定向后全量 :-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 托管生命周期
卡点急救
Tests run: 0 :类名没按 *Test 结尾,或导入了旧 JUnit 4 的 @Test;检查 import 是否为 org.junit.jupiter.api.Test。
中文方法名在报告里乱码 :Maven 编码问题,给 maven-surefire-plugin 与 maven-compiler-plugin 都配 -Dfile.encoding=UTF-8(或 project.build.sourceEncoding)。
@TempDir 字段始终 null :JUnit 版本低于 5.9 不支持字段注入;升级 Boot 管理的 JUnit 版本,或改用方法参数注入。
面试常问
@BeforeEach 与 @BeforeAll 的区别与选用?
BeforeEach 每个用例前执行,保证隔离,最常用;BeforeAll 类级一次、默认需 static,适合建昂贵共享资源(容器、连接池)。隔离优先时选前者,成本敏感且只读时选后者。
参数化测试的价值是什么?
把「同一规则 × 多组数据」收成一张表:减少复制粘贴、每行数据在报告里独立计数、新增边界只加一行。配合 @NullAndEmptySource 可把 null/空覆盖变成习惯。
怎么保证测试之间互不影响?
三件事:fixture 在 @BeforeEach 重建;不依赖执行顺序(JUnit 默认顺序不确定);不共享可变静态状态。CodeForge 的 SubmissionAudit 练习就是反例教材——静态累加列表会让两个用例互相污染。
自测三问
Arrange-Act-Assert 三步各自做什么?
什么时候用 @ParameterizedTest 比复制三个 @Test 更合适?
@TempDir 解决了文件类测试的什么痛点?
小结一句话 @Test + assertThat + 独立 setUp = 单元测试骨架;有外部依赖再用 Mockito 替身。
延伸