大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
JUnit 5 基础
这一课你将能做到
- 写出带
@Test与 AssertJassertThat的可运行单元测试类,并读懂失败报告 - 用
@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 一致
本地与 CI 同命令,是后面所有课的前提。
讲清楚要点
- 每个
@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 替身。
延伸
- JUnit 5 User Guide
- AssertJ 官方文档
- 下一课:test-mockito 用 Mockito 隔离 Mapper 与静态工具,给 Service 层写替身测试。