《Java 测试入门:JUnit 5 与 Maven 测试实践》
这篇写给会写 Java、但测试还停留在 main 方法里 System.out 自查的读者,或者从 Python/JS 过来、想看看 Java 这边测试怎么组织的人。JUnit 的 Jupiter 模型学会一次能用很多年,参数化测试和 Mock 的套路在别的语言里也大同小异,是性价比极高的一笔投资。
版本口径先交代清楚,这条时间线容易迷糊:JUnit 5.11 是 2024 年中的稳定线,5.12.0 于 2025-02-20 发布;JUnit 6.0.0 已于 2025-09-30 正式 GA(Java 17 基线、Platform/Jupiter/Vintage 统一版本号),Spring Boot 3.5.x 默认带 JUnit 5.12 一线,Spring Boot 4.0(2025-11 GA)起默认带 JUnit 6.0.x。本文按 JUnit 5 的 Jupiter API 写,5.11/5.12 通用;这些代码迁到 6 基本不用动——6 主要是基线和版本号变化,编程模型延续。工具版本:Maven Surefire 3.x(3.5.4,2025-09-10 发布),Mockito 5.x(5.20.0,2025-09-20 发布),JDK 21 LTS(25 也是 LTS,代码都兼容)。
边界先说死:不比较 TestNG/JUnit 4,不讲测试金字塔理论,Spring 只给一个切片测试示例不展开全家桶;数据层集成测试(Testcontainers 那类)与端到端验收不在本文范围。
从零开始:Maven 里测试怎么跑起来
不引入 Spring 的话,pom 里只需要一个依赖加 Surefire 插件:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.12.2</version>
<scope>test</scope>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
</plugins>
</build>
测试类放 src/test/java,与被测类同名包。跑起来:
mvn test
预期输出里关键几行:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
几个日常命令:单跑一个类 mvn test -Dtest=PriceCalculatorTest(多个用逗号分隔);临时跳过用 -DskipTests——注意是临时,别写进 pom。测试结果报告在 target/surefire-reports/,文本与 XML 各一份,CI 解析读的就是它。目录约定也交代一句:src/main/java 放生产代码,src/test/java 放测试,两边包结构镜像;测试专用的数据文件放 src/test/resources。把测试当二等公民乱放的人,重构时第一个找不到自己的用例。
我自己踩过的第一个坑就在这里:新工程 mvn test 显示跑了 0 个测试还 BUILD SUCCESS——多半是老版本 Surefire 没认出 Jupiter 引擎,把 Surefire 升到 3.5.x 就好。反过来的坑是接手别人的项目先翻翻 pom 里有没有常驻的 skipTests 配置:有人为了”本地快点”永久跳过测试,CI 绿得很假,接手当天就该把这种配置揪出来。
生命周期:@BeforeEach 与默认的”每测一例”
class PriceCalculatorTest {
private PriceCalculator calculator;
@BeforeEach
void setUp() {
calculator = new PriceCalculator();
}
@Test
void roundsToTwoDecimals() {
assertEquals(19.99, calculator.round(19.987));
}
@Test
void nullPriceBecomesZero() {
assertEquals(0.0, calculator.round(null));
}
}
执行顺序:@BeforeAll(static,全类一次)→ 每个测试方法前 @BeforeEach → 方法 → @AfterEach → 全类结束 @AfterAll,AfterAll 的典型用途是关共享资源(数据库容器、临时目录)。重点:JUnit 默认为每个测试方法新建一个测试类实例,方法之间不共享字段——这是设计出来的隔离,不是浪费。别用 static 字段在测试间”偷传状态”,我见过这么干的团队,测试单跑全绿、合起来随机红,排查一整晚。需要昂贵资源全类共享时,用 @TestInstance(PER_CLASS) 或 static,并保持共享对象只读。
断言与异常测试
Jupiter 自带断言够用,多条件场景用 assertAll 一次报出所有失败点,而不是查一个改一次再撞下一个:
@Test
void orderSummaryIsValid() {
Order order = calculator.summary(List.of("a", "b"));
assertAll(
() -> assertEquals(2, order.count()),
() -> assertFalse(order.items().isEmpty())
);
}
异常测试我固定用 assertThrows,它返回异常对象,能继续断言信息:
@Test
void rejectsNegativeQuantity() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> calculator.summary(List.of("a", "b"), -1)
);
assertTrue(ex.getMessage().contains("quantity"));
}
异常类型的层次怎么设计、checked 与 unchecked 的取舍,我在《Java 异常处理》里写过。测试这节只强调一句:断言异常类型加关键信息,只断类型会把”凑巧也抛这个类”的 bug 放过去;顺手给容易失败的断言补一条消息参数,CI 日志的可读性天差地别。浮点断言同理,别拿 assertEquals 直怼 double,用带 delta 的重载 assertEquals(19.99, result, 0.001),把表示误差明示出来。
参数化测试:一套断言吃掉十组输入
@ParameterizedTest
@CsvSource({
"19.987, 19.99",
"0.001, 0.0",
"-2.345, -2.35"
})
void roundsCorrectly(double raw, double expected) {
assertEquals(expected, calculator.round(raw));
}
@ValueSource 喂单参数,@CsvSource 喂多参数,复杂对象用 @MethodSource 指向一个返回 Stream 的静态工厂方法;空值与空集合场景用 @NullSource、@EmptySource(可组合成 @NullAndEmptySource),比在 CSV 里手写空串清晰。我的习惯是边界值全进参数化表:空串、0、负数、超大数、空集合。把”再测一组”的成本降到加一行字符串,写测试才不会偷懒。
Mockito 5:把依赖换成替身
测 OrderService 时不该真的去调数据库。Mockito 生成依赖的替身,验证”调用发生了、参数对、返回被用上了”:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository repository;
@InjectMocks OrderService service;
@Test
void createsOrderAndSaves() {
when(repository.save(any(Order.class)))
.thenAnswer(inv -> inv.getArgument(0));
Order order = service.create("sku-1", 2);
assertEquals("sku-1", order.sku());
verify(repository).save(order);
}
}
依赖:mockito-core 加 mockito-junit-jupiter,均为 5.20.0 口径(Mockito 5 的 inline mock maker 默认开启,mock final 类不用再配 agent,老教程里那一步可以删了)。想断言”传给依赖的对象内部字段对不对”,用 ArgumentCaptor 把实参抓出来:
@Captor ArgumentCaptor<Order> orderCaptor;
@Test
void savesOrderWithCorrectSku() {
service.create("sku-1", 2);
verify(repository).save(orderCaptor.capture());
assertEquals("sku-1", orderCaptor.getValue().sku());
}
三条经验:只 mock 你拥有的接口,直接 mock 第三方客户端的话库一升级测试全假;verify 别过量,验证”存了一次”就够,验证”调了三遍且第二遍参数是”是跟实现绑死的脆测试;测试数据自己写静态工厂方法构造,别让打桩逻辑兼职造数据,两者的修改节奏不同。另外 MockitoExtension 默认严格模式,多余的 when 打桩会直接报 UnnecessaryStubbingException——别为了”以后可能用”提前打桩,用不到的桩就是噪音。
手写 Fake 与 Mock 的分工
Mockito 不是唯一选择,很多场景手写 Fake 更好维护:一个内存版的 OrderRepository,十来行代码,行为真实——真能存、真能查,多个测试类直接复用。我的分工标准:验证交互(调没调、调几次、参数对不对)用 Mockito;需要行为(存进去要查得出来)用 Fake。比如 OrderService 的常规路径测试全用内存 Fake 撑着,只在验证”save 确实被调用了”的那一处上 Mockito,测试跑得快,读起来也像业务故事。替身是工具箱不是信仰,别让 mock 成了条件反射。
@Tag:给测试分速
跑得慢的测试(起数据库、走网络)用 @Tag("slow") 标注,日常本地只跑快的:mvn test -DexcludedGroups=slow,CI 里全量跑;反过来 -Dgroups=slow 单独跑慢组。这个机制解决的是”因为慢所以没人跑测试”的经典困局——把慢测试隔离出来而不是删掉,速度与覆盖都保住。分档命名我的习惯是 slow 与 integration 两级,团队统一比层级精细更重要。构建提速的并行参数(forkCount、parallel)先别动:单线程跑得稳再谈并行,测试脏了并行只会放大问题。
@Nested:按场景分组
用例一多,平铺的十几个方法看不出结构。@Nested 内嵌类把用例按”给定条件”分组,每个嵌套类可以有自己的 @BeforeEach:
@Nested
class WhenCartIsEmpty {
@BeforeEach
void initEmptyCart() { service.useEmptyCart(); }
@Test
void checkoutIsRejected() {
assertThrows(IllegalStateException.class, service::checkout);
}
}
@Nested
class WhenCartHasItems {
@BeforeEach
void initFilledCart() { service.add("sku-1", 2); }
@Test
void checkoutSucceeds() { assertNotNull(service.checkout()); }
}
执行规则:外层生命周期方法先跑,嵌套类的后跑;测试报告里嵌套结构也原样呈现,失败定位一眼见场景。我把”同一前提下的所有用例”收进一个 Nested,比在方法名里拼 when_xxx_then_yyy 前缀干净得多。
Spring Boot 切片:起步用 @WebMvcTest
Spring 项目里别一上来就 @SpringBootTest(整个上下文起一遍,慢),先学切片:@WebMvcTest 只装配 Web 层,Controller 的契约(路径、状态码、参数绑定)验得又快又准:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService service;
@Test
void returns200ForValidOrder() throws Exception {
when(service.create("sku-1", 2)).thenReturn(new Order("sku-1", 2));
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{"sku":"sku-1","qty":2}"))
.andExpect(status().isOk());
}
}
一个版本敏感点:@MockBean 已在 Spring Boot 3.4 弃用、4.0 移除,替代品是 Spring Framework 6.2 的 @MockitoBean,老项目里看到 @MockBean 就是还没升 3.4+ 的信号。至于全量 @SpringBootTest,留给少数关键路径的冒烟——每加一个都翻倍 CI 时间,值得像预算一样花。框架与依赖注入的铺垫见《Spring Boot 入门》。语法层面的新写法(record 当测试数据载体、文本块构造 JSON 断言串)在《Java 现代特性》里有成套示例,测试代码里用它们,可读性立竿见影。
测试的可读性:命名与数据构造
命名我的约定是”方法名说场景,断言说结果”。JUnit 完全支持中文方法名,空购物车_结算被拒绝 这类名字在报告里自解释,比 test1 强一个时代,团队接受就大胆用。数据构造别在每个测试里 new 五层对象——写个静态工厂 anOrder().withSku("sku-1").qty(2).build(),测试数据像句子一样长出来。测试代码里的重复也值得消灭:它唯一的读者是半年后的你和接手的人,可读性是测试代码的第一指标。团队里还有一条低成本高回报的规矩:修 bug 先写一条能复现它的失败用例,修复后变绿——bug 的前科就此留档,回归测试自动长出来。从 main 方法自查迁移过来的读者,路径就三步:把判断从 System.out 换成 assertEquals,把每个场景复制成独立 @Test,把样例数据塞进参数化表——一整个 main 自查类,半小时能翻译完。
常见坑清单
- skipTests 常驻配置里。“本地先跳过”变成永久跳过,CI 的绿是假的;提速需求用命令行临时传参,跑完补回来。
- 测试间顺序依赖。JVM 不保证方法执行顺序,上一个测试改的静态状态会污染下一个;单跑绿、全跑红的测试先怀疑这个。
- 断言不带信息。
assertTrue(x != null)失败时什么都不说;用assertEquals或带消息的断言,失败输出直接给答案。 - mock 掉被测对象自己。spy 部分打桩是高级操作,新手拿它绕开难测的设计——难测本身往往就是设计问题的信号。
- 没有超时保护。碰外部资源的测试加
@Timeout(2),别让一次网络抖动把 CI 挂半小时。 - 不确定的输入源。测试里用 LocalDate.now()、随机数、当前目录路径这类会变的东西,今天绿明天红;时间用固定值或注入 Clock,随机固定种子。
验收清单
mvn test实跑 3 个以上测试且计数非 0;- 故意改坏一处断言,确认 BUILD FAILURE 且报告定位到方法行号;
- 边界值参数化表至少含空集合与非法输入两组;
- Mock 测试删掉
when打桩后测试变红(证明替身真的在工作); - 能解释”每个测试方法新实例”为什么是默认行为;
- Spring 项目至少有一条 @WebMvcTest 切片用例,用的是 @MockitoBean;
- @Tag 分速在本地与 CI 各跑过一次,分组结果符合预期。
诚实收尾:本文的模板足够撑起服务层的日常测试,但覆盖不到的两块别误以为不存在——数据层的集成测试(Testcontainers 起真数据库)与端到端验收,是下一步要补的课,等第一套回归测试跑稳、业务真的需要时再学不迟。
本文数据核对时间:2026-02-09:JUnit 5.11(2024 年中)→ 5.12.0(2025-02-20 发布)→ JUnit 6.0.0(2025-09-30 GA,Java 17 基线);Spring Boot 3.5.x 默认 JUnit 5.12 一线、4.0(2025-11 GA)默认 JUnit 6.0.x;@MockBean 自 Boot 3.4 弃用、4.0 移除(替代为 Framework 6.2 的 @MockitoBean);Maven Surefire 3.5.4(2025-09-10 发布);Mockito 5.20.0(2025-09-20 发布);JDK 21/25 均为 LTS。均为官方发布口径。