
我之前带团队的时候,就遇到过这种情况:有个项目上线三个月,光修复测试没覆盖到的bug就花了20多个人天。后来我们复盘发现,不是大家不想写测试,而是很多人觉得单元测试“麻烦”“不知道从哪下手”,尤其对JUnit5这种新框架,看着官方文档里一堆注解就头大。今天我就把自己带团队时 的“JUnit5实战手册”分享给你,从环境搭建到高级技巧,全是能直接上手的干货,亲测带新人的时候这么教,他们一周内就能写出规范的测试用例。
JUnit5基础上手:环境搭建与核心注解实战
环境搭建:3步搞定Maven/Gradle配置
很多人觉得“搭环境”是个坎,其实JUnit5的配置比你想的简单多了。不管你用Maven还是Gradle,3步就能搞定,我带的实习生按这个步骤走,最快5分钟就能跑通第一个测试。
第一步:添加依赖
。如果你用Maven,直接在pom.xml里加这几行(记得把版本号换成最新的,现在稳定版是5.10.0):
<!-
JUnit5核心依赖 >
org.junit.jupiter
junit-jupiter-api
5.10.0
test
<!-
测试运行器,确保IDEA或Maven能执行测试 >
org.junit.jupiter
junit-jupiter-engine
5.10.0
test
如果你用Gradle,就在build.gradle里加:
dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0'
}
test {
useJUnitPlatform() // 启用JUnit5平台
}
我之前帮一个朋友配置Gradle的时候,他漏了最后一行useJUnitPlatform()
,结果测试跑不起来,折腾了半小时,你可别犯这个错。
第二步:创建测试类
。在Java项目里,测试类一般放在src/test/java
目录下,包名和被测试类保持一致。比如你有个com.example.service.UserService
,测试类就叫UserServiceTest
,这样找起来一目了然。IDEA里可以直接右键被测试类,选“Generate”→“Test”,自动生成测试类模板,特别方便。 第三步:写第一个测试方法。最简单的测试方法长这样:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals;
public class CalculatorTest {
@Test
void add() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
assertEquals(5, result); // 断言结果是否为5
}
}
你可能会问,就这么简单?对,JUnit5的设计理念就是“简洁易用”。点一下IDEA左侧的运行按钮,测试通过会显示绿色对勾,失败会显示红色波浪线,还会告诉你预期5实际多少,特别直观。我见过有人写测试非要搞复杂的结构,其实大可不必,简单的测试才容易维护。
核心注解解密:从@Test到@AfterAll的正确打开方式
搞定了环境,接下来你得掌握JUnit5的核心注解——这些“标签”能帮你控制测试的执行流程。我见过不少人乱用注解,比如用@BeforeAll
初始化数据库连接,结果测试互相干扰,最后还怪框架不好用。其实是你没搞懂每个注解的“脾气”。
先看一张我整理的JUnit5常用注解对比表,帮你快速理清思路:
注解名称 | 作用 | 使用场景 | 注意事项 |
---|---|---|---|
@Test | 标记测试方法 | 所有需要执行的测试用例 | 无返回值,可抛异常 |
@BeforeEach | 每个测试方法前执行 | 初始化测试对象(如new UserService()) | 非静态方法 |
@AfterEach | 每个测试方法后执行 | 清理资源(如关闭文件流) | 非静态方法 |
@BeforeAll | 所有测试方法前执行1次 | 初始化全局资源(如数据库连接池) | 必须是静态方法 |
@AfterAll | 所有测试方法后执行1次 | 销毁全局资源(如关闭连接池) | 必须是静态方法 |
表格里有个关键点:@BeforeEach
和@BeforeAll
的区别。你可能会问,为什么需要“每个测试前执行”和“所有测试前执行”两种?举个例子:假设你测试一个用户服务,每个测试方法都需要一个新的UserService
对象(避免测试互相影响),这时候用@BeforeEach
;但数据库连接池只需要初始化一次,就用@BeforeAll
。我之前有个同事把@BeforeAll
写成了非静态方法,结果IDEA直接报错,你写的时候记得检查一下。
再说说@Test
注解,JUnit5的@Test
比JUnit4强大多了——它支持直接抛异常,比如测试某个方法在参数错误时会抛异常,你可以这么写:
@Test void divide_WhenDenominatorZero_ShouldThrowException() {
Calculator calculator = new Calculator();
assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0));
}
这里的assertThrows
是JUnit5自带的断言方法,不用像JUnit4那样写try-catch
,简洁多了。你想想,要是用JUnit4,得写try { calculator.divide(1,0); fail(); } catch (ArithmeticException e) {}
,多麻烦!
还有个注解你可能会用到:@Disabled
,用来暂时禁用某个测试。比如某个功能还在开发中,测试跑不通,你可以标上@Disabled("功能开发中,暂不测试")
,这样构建的时候就会跳过它,等功能好了再打开。我之前做迭代开发的时候,经常用这个注解,避免测试失败影响整个构建流程。
JUnit5高级技巧:从参数化测试到Mock框架集成
学会了基础,你可能会觉得“写单个测试还行,但重复代码好多”——比如测试一个加法方法,要测正数+正数、正数+负数、零+零,难道要写三个测试方法?这时候就需要“参数化测试”来拯救你了。再往深了说,当你的代码依赖数据库、Redis这些外部服务时,怎么测试才能不连真实环境?这就需要Mock框架了。这部分我会带你一步步搞定这些“进阶难题”。
参数化测试:让一个测试用例跑遍所有场景
我见过最夸张的测试代码,是一个校验手机号格式的方法,写了10个测试方法,分别测试11位数字、少于11位、多于11位、有字母的情况……代码重复得不行,改一点逻辑就要改10个地方。其实用JUnit5的参数化测试,一行代码能顶10行用。
参数化测试的核心思想是:用不同的参数多次运行同一个测试方法。要想用起来,你需要先加个依赖(Maven):
org.junit.jupiter
junit-jupiter-params
5.10.0
test
然后用@ParameterizedTest
注解代替@Test
,再指定参数来源。最常用的参数来源有四种,我给你一一举例:
第一种:@ValueSource
——适合简单的单参数场景。比如测试手机号长度:
@ParameterizedTest @ValueSource(strings = {"13800138000", "13912345678"}) // 正确的手机号
void validatePhone_ValidNumbers_ShouldReturnTrue(String phone) {
PhoneValidator validator = new PhoneValidator();
assertTrue(validator.validate(phone));
}
@ParameterizedTest
@ValueSource(strings = {"12345", "138001380000", "abc123456789"}) // 错误的手机号
void validatePhone_InvalidNumbers_ShouldReturnFalse(String phone) {
PhoneValidator validator = new PhoneValidator();
assertFalse(validator.validate(phone));
}
这样一个测试方法,会分别用@ValueSource
里的每个参数跑一遍,IDEA的测试结果里会显示“[1] 13800138000”“[2] 13912345678”,清晰得很。
第二种:@CsvSource
——适合多参数场景。比如测试加法,要传两个参数(a和b)和预期结果:
@ParameterizedTest @CsvSource({
"2, 3, 5", // 2+3=5
"-1, 1, 0", // -1+1=0
"0, 0, 0" // 0+0=0
})
void add(int a, int b, int expected) {
Calculator calculator = new Calculator();
assertEquals(expected, calculator.add(a, b));
}
这里的@CsvSource
用逗号分隔参数,一行就是一组测试数据。你看,原来要写三个测试方法,现在一个就够了,维护起来多方便!
第三种:@MethodSource
——适合复杂参数生成。如果你的测试数据需要通过代码生成(比如从文件读数据),就用这个。先写个静态方法返回Stream
:
private static Stream provideDivisionTestCases() { return Stream.of(
Arguments.of(6, 2, 3), // 6/2=3
Arguments.of(5, -1, -5), // 5/-1=-5
Arguments.of(0, 5, 0) // 0/5=0
);
}
@ParameterizedTest
@MethodSource("provideDivisionTestCases")
void divide(int dividend, int divisor, int expected) {
Calculator calculator = new Calculator();
assertEquals(expected, calculator.divide(dividend, divisor));
}
这种方式灵活性最高,我之前测试一个订单金额计算方法,需要从Excel读100组测试数据,就是用@MethodSource
调用POI读取Excel,特别方便。
可能你会担心:参数化测试跑这么多遍,会不会很慢?其实单元测试本来就应该快,如果你觉得慢,可能是测试里做了太多IO操作(比如连数据库),这时候就需要Mock框架来解决了。
Mockito集成:解决外部依赖的测试难题
“我写的代码要调数据库,不连数据库怎么测试?”这是我被问得最多的问题。答案是:用Mock框架“假装”一个数据库。现在最流行的Mock框架是Mockito,和JUnit5配合得天衣无缝。
先说说为什么需要Mock。比如你有个UserService
,它依赖UserRepository
(数据库访问层):
@Service public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("用户不存在"));
}
}
测试getUserById
时,如果连真实数据库,万一数据库里没数据、或者数据被改了,测试就会失败。这时候我们可以用Mockito“Mock”一个UserRepository
,让它按我们的想法返回数据。
第一步:添加依赖
(Maven):
org.mockito
mockito-core
4.11.0
test
org.mockito
mockito-junit-jupiter
4.11.0
test
第二步:用@Mock和@InjectMocks创建Mock对象
:
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ExtendWith(MockitoExtension.class) // 启用Mockito扩展
class UserServiceTest {
@Mock // Mock一个UserRepository
private UserRepository userRepository;
@InjectMocks // 把Mock的userRepository注入到UserService
private UserService userService;
@Test
void getUserById_WhenUserExists_ShouldReturnUser() {
//
准备测试数据
Long userId = 1L;
User mockUser = new User(userId, "张三");
//
告诉Mock对象:当调用findById(1L)时,返回mockUser
when(userRepository.findById(userId)).thenReturn(Optional.of(mockUser));
//
调用被测试方法
User result = userService.getUserById(userId);
//
断言结果
assertEquals("张三", result.getName());
}
@Test
void getUserById_WhenUserNotExists_ShouldThrowException() {
// 告诉Mock对象:当调用findById(999L)时,返回空Optional
when(userRepository.findById(999L)).thenReturn(Optional.empty());
// 断言会抛出UserNotFoundException
assertThrows(UserNotFoundException.class, () -> userService.getUserById(999L));
}
}
这里的关键是when(...).thenReturn(...)
——你可以理解为“给Mock对象写剧本”:当它收到某个调用时,就返回你指定的数据。这样测试就完全不依赖真实数据库了,跑起来飞快,而且结果稳定。
我之前带团队推广Mockito的时候,有个同事问:“怎么确定Mock的逻辑对不对?万一我Mock错了,测试通过但实际代码有问题怎么办?”这就要说到“验证Mock调用”了。比如你想确认userRepository.findById
被调用了一次,可以用verify
:
java
import static org.mockito.Mockito.verify;
@Test
void getUserById_ShouldCallRepositoryOnce() {
Long userId = 1L;
when(userRepository.findById(userId)).thenReturn(Optional.of(new User(userId, “张三”)));
userService.getUserById
你用JUnit4的时候是不是觉得框架有点“死板”?JUnit5最大的变化就是把自己拆成了好几个灵活的部分,就像搭积木一样各司其职。它主要分三块:JUnit Platform是“舞台”,负责运行测试,不管是JUnit5自己的测试,还是老的JUnit3/4测试,甚至其他框架的测试,都能在这上面跑;JUnit Jupiter是“演员”,提供了新的编程模型,像那些@ParameterizedTest注解、Lambda表达式支持,都是它的活儿;还有个JUnit Vintage,专门用来“兼容老演员”,如果你项目里还有没来得及升级的JUnit4测试用例,靠它就能继续跑,不用一下子全改完。我之前带团队从JUnit4升到JUnit5的时候,就是先用Vintage过渡,把老测试保住,再慢慢用Jupiter写新测试,特别省心。
除了架构灵活,JUnit5对Java 8的支持也让代码清爽了不少。你想想,JUnit4里写个断言抛异常,得用try-catch包起来,还得手动fail(),现在用Jupiter的assertThrows,直接传个Lambda表达式就行,比如assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0)),一行搞定。注解也换了新的,JUnit4的@Before现在叫@BeforeEach,@After叫@AfterEach,名字更直白——“每个测试前”“每个测试后”,一看就知道啥意思。最舒服的是测试方法终于不用写public了,以前写@Test public void test() {},现在直接@Test void test() {},少敲好几个字母,日积月累能省不少事儿。我见过有人升级后还习惯性写public,结果IDEA直接标黄提醒“这没必要啦”,特别有意思。
功能上也加了不少“实用工具”,比如原生支持参数化测试。JUnit4的时候想跑多组数据,得装第三方插件,现在@ValueSource、@CsvSource这些注解直接能用,像测试手机号校验,以前要写五六个@Test方法,现在一个@ParameterizedTest加@CsvSource就能把各种情况列全,代码量少了一半还多。动态测试也很赞,能在运行时动态生成测试用例,比如从文件里读测试数据,读多少条就生成多少个测试,特别适合数据驱动的场景。这些功能加起来,让写测试从“不得不做的任务”变成了“还挺顺手的事儿”,至少我团队里的人用了半年后,再也没人抱怨写测试麻烦了。
JUnit5和JUnit4有什么主要区别?
JUnit5相比JUnit4的核心区别在于架构模块化和功能增强:JUnit5由JUnit Platform(平台)、JUnit Jupiter(编程模型)和JUnit Vintage(兼容JUnit3/4)三部分组成,支持Java 8及以上特性(如Lambda表达式、Stream API);注解方面,JUnit5使用@BeforeEach/@AfterEach替代JUnit4的@Before/@After,@BeforeAll/@AfterAll需配合static关键字,且测试方法不再要求public修饰; JUnit5原生支持参数化测试、动态测试,断言方法更丰富(如assertThrows、assertTimeout),整体更灵活易用。
单元测试覆盖率达到多少才算合格?
单元测试覆盖率没有绝对统一的标准,需结合项目类型和团队规范。一般 核心业务逻辑覆盖率不低于80%,工具类、通用组件可适当放宽至60%-70%。但需注意“为覆盖率而测试”的误区——覆盖率高不代表测试质量好,关键是覆盖边界场景(如异常处理、空值校验)和核心逻辑分支。可通过JaCoCo等工具生成覆盖率报告,重点关注未覆盖的代码块是否存在风险。
参数化测试适合哪些场景?
参数化测试适合需要多组输入验证同一逻辑的场景,例如:
Mockito只能Mock接口吗?可以Mock具体类吗?
Mockito不仅可以Mock接口,也支持Mock具体类(需满足条件)。对于非final的具体类,可直接通过Mockito.mock(Class)方法创建Mock对象;但final类、static方法、final方法默认无法Mock,需通过Mockito的inline扩展(添加mockito-inline依赖)或PowerMock等工具处理。实际开发中, 优先依赖接口设计(如Service依赖Repository接口),便于Mock和降低耦合。
编写单元测试时,如何处理静态方法的依赖?
静态方法因耦合性高,测试时较难Mock, 优先通过重构减少静态方法依赖(如将静态工具类改为实例类,通过构造函数注入)。若必须处理,可使用工具方案: