JUnit 简介
JUnit 是 Java 生态最主流的单元测试框架,用来对单个方法 / 类做自动化测试,把测试代码写在项目里,一键运行,验证逻辑是否符合预期。
现在主流版本:JUnit 5(Jupiter),老项目多见 JUnit 4。
JUnit 5 整体结构
JUnit5 由三部分组成:
- JUnit Jupiter :写测试用例(API,
@Test等注解) - JUnit Platform:测试运行引擎,IDE/Maven/Gradle 底层依赖
- JUnit Vintage:兼容 JUnit4 的旧测试用例
常用注解(JUnit5)
| 注解 | 作用 |
|---|---|
@Test |
标记一个方法是测试方法 |
@BeforeEach |
每个测试方法执行前执行(初始化) |
@AfterEach |
每个测试方法执行后执行(清理) |
@BeforeAll |
所有测试方法执行前 ,只执行一次;方法必须 static |
@AfterAll |
所有测试方法执行完 ,只执行一次;方法必须 static |
@Disabled |
跳过该测试用例(不执行) |
@DisplayName("测试名称") |
自定义测试显示名字,可读性更好 |
@Nested |
嵌套测试类,用于分组测试 |
JUnit4 对比:
@Before/@After/@BeforeClass/@AfterClass,JUnit5 改名了。
断言 Assertions(核心)
断言就是预期结果 vs 实际结果,不匹配就抛出断言错误,测试失败。
java
import static org.junit.jupiter.api.Assertions.*;
assertEquals(预期值, 实际值); // 相等
assertNotEquals(a,b);
assertTrue(布尔表达式);
assertFalse(布尔表达式);
assertNull(obj);
assertNotNull(obj);
assertAll(() -> { // 组合断言,一次性展示所有失败,不是遇到第一个就终止
assertEquals(1, a);
assertEquals(2, b);
});
// 异常断言:验证代码是否抛出指定异常
assertThrows(NullPointerException.class, () -> {
// 要执行的代码
});
完整最小示例(JUnit5)
Maven 依赖 pom.xml
XML
<!-- JUnit5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
业务类 Calculator.java
java
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int div(int a, int b) {
if(b == 0){
throw new ArithmeticException("除零");
}
return a / b;
}
}
测试类 CalculatorTest.java(放在 src/test/java)
java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("计算器测试")
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("测试加法")
void testAdd() {
int res = calculator.add(1,2);
assertEquals(3, res);
}
@Test
@DisplayName("测试除零抛异常")
void testDivZero() {
ArithmeticException e = assertThrows(ArithmeticException.class, ()->{
calculator.div(10,0);
});
assertEquals("除零", e.getMessage());
}
@AfterEach
void tearDown(){
// 资源释放
}
}
在 IDEA 可以直接点运行,Maven:mvn test 自动执行所有测试。
测试生命周期
java
@BeforeAll
@BeforeEach
@Test
@AfterEach
@BeforeEach
@Test
@AfterEach
@AfterAll
参数化测试(JUnit5 很常用)
同一个方法,多组输入自动跑:
XML
<!-- 需要额外引入参数化依赖 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
java
@ParameterizedTest
@ValueSource(ints = {1,3,5,7})
void testOdd(int num){
assertTrue(num %2 !=0);
}
JUnit4 vs JUnit5 重点区别
- 包路径:JUnit4
org.junit;JUnit5org.junit.jupiter.api - 注解改名:
@Before→@BeforeEach;@BeforeClass→@BeforeAll - 断言类:JUnit4
Assert;JUnit5Assertions - JUnit5 支持 Java8+ lambda,更灵活
常见搭配
- AssertJ :流式断言,可读性更强
assertThat(res).isEqualTo(3) - Mockito:mock 依赖对象(做单元测试时,把 DB、RPC 外部依赖模拟掉)
- Maven/Gradle:CI 流水线自动跑单元测试
单元测试原则
- 单一职责:一个测试方法只测一个场景
- 独立性:测试用例之间互不影响,不能共享脏数据
- 覆盖:正常场景、边界值、异常场景都要测
JUnit5 + Mockito示例
1. JUnit5 + Mockito 完整 Demo(mock Service)
场景:UserService 依赖 UserDao,单元测试时不连真实数据库,mock UserDao
Maven 依赖
XML
<!-- JUnit5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<!-- Mockito JUnit5 扩展 -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.11.0</version>
<scope>test</scope>
</dependency>
业务代码
java
// UserDao
public interface UserDao {
String getUserNameById(Long id);
}
// UserService
public class UserService {
private final UserDao userDao;
public UserService(UserDao userDao) {
this.userDao = userDao;
}
public String getUsername(Long userId) {
if (userId == null || userId <= 0) {
throw new IllegalArgumentException("非法用户id");
}
return userDao.getUserNameById(userId);
}
}
测试类 UserServiceTest
java
import org.junit.jupiter.api.DisplayName;
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 static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // 开启Mockito
@DisplayName("用户服务单元测试")
class UserServiceTest {
// mock外部依赖,不是真实对象
@Mock
private UserDao userDao;
// 将mock的userDao注入到userService
@InjectMocks
private UserService userService;
@Test
@DisplayName("正常查询用户名")
void testGetUsernameSuccess() {
// 打桩stub:当userDao调用getUserNameById(1L),返回"张三"
when(userDao.getUserNameById(1L)).thenReturn("张三");
// 执行方法
String name = userService.getUsername(1L);
// 断言结果
assertEquals("张三", name);
// 验证userDao.getUserNameById(1L)被调用了1次
verify(userDao, times(1)).getUserNameById(1L);
}
@Test
@DisplayName("id<=0抛出非法参数异常")
void testGetUsernameFail() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> {
userService.getUsername(-1L);
});
assertEquals("非法用户id", ex.getMessage());
// 此时不应该调用dao
verify(userDao, never()).getUserNameById(anyLong());
}
}
核心概念
@Mock:创建模拟对象@InjectMocks:实例化被测对象,自动注入 mock 对象when(...).thenReturn(...):打桩(stub),模拟返回值verify:校验方法是否被调用、调用次数
2. JUnit 面试高频题(带答案)
Q1:JUnit5 有哪些生命周期注解,执行顺序?
A:
@BeforeAll:所有测试方法前执行一次,static@BeforeEach:每个 @Test 前执行@Test:测试方法@AfterEach:每个 @Test 后执行@AfterAll:所有测试结束后执行一次,static
注意:测试方法不能是 private,返回值 void。
Q2:JUnit5 和 JUnit4 的主要区别?
A:
- 包名:JUnit4
org.junit;JUnit5org.junit.jupiter.api - 注解改名:
@Before→@BeforeEach;@BeforeClass→@BeforeAll - JUnit5 支持 Java8 lambda,支持组合断言、参数化测试
- JUnit5 模块化,平台 + jupiter+vintage,vintage 用来跑 JUnit4 旧用例
- JUnit4 只有一个运行器;JUnit5 扩展模型 Extension 替代 Runner
Q3:什么是断言?assertAll 作用?
A:断言用来校验预期结果,失败抛出 AssertionError,标记测试失败。 assertAll:分组断言,一次性执行全部断言,收集所有失败信息,不会遇到第一个失败就终止。
java
assertAll("用户信息校验",
() -> assertEquals(1, user.getId()),
() -> assertEquals("test", user.getName())
);
Q4:@Disabled 作用?和注释掉测试方法区别?
A:@Disabled 跳过执行该测试,测试报告会标记该用例为跳过。 注释掉:测试框架看不到这个测试方法,报告不会出现这条用例。
Q5:Mockito 中 @Mock、@Spy、@InjectMocks 区别
A:
@Mock:完全模拟对象,所有方法默认返回 null / 默认值,需要手动打桩@Spy:部分模拟,真实对象,不打桩时执行真实方法;打桩后返回 mock 值@InjectMocks:创建被测类实例,把 @Mock/@Spy 对象注入进去
Q6:单元测试的原则?
A:AIR 原则:
- Automatic:自动化,一键执行
- Independent:测试用例互相独立,不能依赖执行顺序
- Repeatable:可重复执行,每次结果一致
还有:单一职责,覆盖正常、边界、异常场景;尽量不依赖外部资源(DB/Redis)。
Q7:参数化测试有什么用?常用注解?
A:同一测试逻辑,多组输入批量执行,减少重复代码。 @ParameterizedTest,搭配:
@ValueSource:简单值(int/string)@CsvSource:多参数@MethodSource:自定义方法提供复杂参数
示例:
java
@ParameterizedTest
@CsvSource({"1,2,3","3,4,7"})
void testAdd(int a, int b, int res){
assertEquals(res, calculator.add(a,b));
}
Q8:assertThrows 的作用?
A:断言代码块抛出指定类型异常,用于异常场景测试,可以拿到异常对象进一步校验 message。
3. JUnit5 Extension 扩展(自定义扩展)
JUnit5 使用 Extension 替代 JUnit4 的 Runner,可在测试生命周期各个节点插入自定义逻辑。 常用扩展点接口:
BeforeAllCallbackBeforeEachCallbackAfterEachCallbackAfterAllCallbackTestExecutionExceptionHandler:捕获测试异常
示例:自定义扩展,打印测试开始 / 结束日志
java
import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
public class LogExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
System.out.println("【测试开始】" + context.getDisplayName());
}
@Override
public void afterEach(ExtensionContext context) {
System.out.println("【测试结束】" + context.getDisplayName());
}
}
使用扩展
java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(LogExtension.class)
class ExtensionDemoTest {
@Test
void testDemo(){
System.out.println("执行测试逻辑");
}
}
常用场景
- 自动初始化 / 清理资源(连接池)
- 全局捕获测试异常,自定义告警
- 自定义注解,实现通用能力(比如自动生成测试数据)
- 埋点统计测试耗时
延伸
- Spy 和 Mock 坑点 :spy 对象调用真实方法,如果方法里面抛异常,即使你打桩也要注意;spy 打桩推荐用
doReturn().when(),不要when().thenReturn()
java
UserDao spyDao = spy(UserDao.class);
// 推荐
doReturn("test").when(spyDao).getUserNameById(1L);
- 单元测试、集成测试区别
- 单元测试:测试单个类 / 方法,mock 所有外部依赖,速度快,开发写
- 集成测试:多个组件一起跑,使用真实 DB / 中间件,验证交互
- Maven surefire 插件 :Maven 执行
mvn test底层靠 surefire 插件,用来发现并执行 JUnit 测试。
JUnit5 + Mockito 面试上机编码题(含参考答案)
面试场景:手写单元测试,考察:@Mock、@InjectMocks、打桩 stub、异常断言、verify 校验,是 Java 后端面试高频手写题
题目
现有业务代码:
java
// 接口
public interface OrderDao {
// 根据订单id查询订单金额
Integer getOrderAmount(Long orderId);
}
// 订单服务
public class OrderService {
private final OrderDao orderDao;
public OrderService(OrderDao orderDao) {
this.orderDao = orderDao;
}
/**
* 判断订单是否大额订单:金额 >1000 为大额订单
* @param orderId 订单id
* @return true大额 / false普通
*/
public boolean isBigOrder(Long orderId) {
if(orderId == null || orderId <= 0){
throw new IllegalArgumentException("订单ID非法");
}
Integer amount = orderDao.getOrderAmount(orderId);
if(amount == null){
return false;
}
return amount > 1000;
}
}
要求:使用 JUnit5 + Mockito 编写 OrderService 的单元测试,覆盖下面 4 个场景:
- 订单金额 1500,返回 true(大额订单)
- 订单金额 800,返回 false(普通订单)
- dao 返回 null,返回 false
- orderId 为 null,抛出非法参数异常,并校验异常信息
参考答案(测试类)
java
import org.junit.jupiter.api.DisplayName;
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 static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("订单服务单元测试")
class OrderServiceTest {
// mock外部依赖
@Mock
private OrderDao orderDao;
// 注入mock对象到被测服务
@InjectMocks
private OrderService orderService;
@Test
@DisplayName("订单金额1500,是大额订单,返回true")
void testIsBigOrderTrue() {
// 打桩:orderId=1001L,dao返回1500
when(orderDao.getOrderAmount(1001L)).thenReturn(1500);
boolean result = orderService.isBigOrder(1001L);
assertTrue(result);
// 校验dao方法调用1次
verify(orderDao, times(1)).getOrderAmount(1001L);
}
@Test
@DisplayName("订单金额800,普通订单返回false")
void testIsBigOrderFalse() {
when(orderDao.getOrderAmount(1002L)).thenReturn(800);
boolean result = orderService.isBigOrder(1002L);
assertFalse(result);
verify(orderDao, times(1)).getOrderAmount(1002L);
}
@Test
@DisplayName("dao返回null,返回false")
void testOrderAmountNull() {
when(orderDao.getOrderAmount(1003L)).thenReturn(null);
boolean result = orderService.isBigOrder(1003L);
assertFalse(result);
verify(orderDao, times(1)).getOrderAmount(1003L);
}
@Test
@DisplayName("orderId为null,抛出异常")
void testOrderIdNull() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, ()->{
orderService.isBigOrder(null);
});
assertEquals("订单ID非法", exception.getMessage());
// id非法,代码不会调用dao,校验never调用
verify(orderDao, never()).getOrderAmount(anyLong());
}
}
配套 Maven 依赖(面试写代码可以顺带提)
XML
<!-- JUnit5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<!-- Mockito JUnit5扩展 -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>4.11.0</version>
<scope>test</scope>
</dependency>
面试追问(面试官大概率接着问,答案给你备好)
Q1:@ExtendWith(MockitoExtension.class)作用?
A:JUnit5 的扩展,自动初始化@Mock、@InjectMocks对象,不用手动MockitoAnnotations.openMocks(this)。
Q2:verify(orderDao, never()).getOrderAmount(anyLong())是什么意思?
A:校验这个方法绝对不能被调用。因为 id 非法时代码直接抛异常,不会走到 dao 查询逻辑。
Q3:anyLong () 是什么?
A:Mockito 参数匹配器,匹配任意 Long 类型参数。如果用参数匹配器,所有参数都要用匹配器。
坑:
when(userDao.getUserNameById(anyLong())).thenReturn("xxx");✔when(userDao.getUserNameById(anyLong(), "abc")).thenReturn("xxx");✔
when(userDao.getUserNameById(anyLong(), "abc")).thenReturn("xxx");❌不能混用常量和匹配器
Q4:@InjectMocks 注入原理?
A:Mockito 实例化被测类,优先尝试构造器注入;没有构造器就 setter 注入;再尝试字段注入,把标记@Mock/@Spy的对象注入进去。
注意:不会注入 static 字段,注入失败不会抛异常,只会警告。
Q5:单元测试里,什么时候用 @Spy?
A:当被测依赖是真实对象,只想 mock 其中一部分方法,其余方法执行真实逻辑。举个例子:OrderDao 是本地工具类,大部分方法想用真实逻辑,只 mock getOrderAmount。
java
@Spy
private OrderDao orderDao;
Spy 打桩坑:不要用
when(spyObj.method()).thenReturn(),优先doReturn().when(spyObj).method(),避免真实方法执行。
可选练习
新题目:新增一个折扣方法
java
/**
* 计算折扣:金额>2000打9折,否则原价
*/
public BigDecimal calcDiscount(Integer amount){
if(amount == null || amount <0){
throw new IllegalArgumentException("金额非法");
}
if(amount >2000){
return new BigDecimal(amount).multiply(new BigDecimal("0.9"));
}
return new BigDecimal(amount);
}
要求写 JUnit5 测试用例,覆盖:金额 2500、金额 1000、金额 null、金额 - 1。
折扣方法 JUnit5 + Mockito 测试参考答案
业务代码:
java
import java.math.BigDecimal;
public class DiscountService {
/**
* 计算折扣:金额>2000打9折,否则原价
*/
public BigDecimal calcDiscount(Integer amount){
if(amount == null || amount <0){
throw new IllegalArgumentException("金额非法");
}
if(amount >2000){
return new BigDecimal(amount).multiply(new BigDecimal("0.9"));
}
return new BigDecimal(amount);
}
}
说明:这个类没有外部依赖,不需要 Mock / @InjectMocks,直接 new 对象测试即可。
测试类:
java
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("折扣服务测试")
class DiscountServiceTest {
private final DiscountService discountService = new DiscountService();
@Test
@DisplayName("金额2500,大于2000,打9折,结果2250")
void testDiscountOver2000(){
BigDecimal result = discountService.calcDiscount(2500);
assertEquals(new BigDecimal("2250"), result);
}
@Test
@DisplayName("金额1000,不大于2000,原价1000")
void testDiscountNormal(){
BigDecimal result = discountService.calcDiscount(1000);
assertEquals(new BigDecimal("1000"), result);
}
@Test
@DisplayName("amount为null,抛出异常")
void testAmountNull(){
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, ()->{
discountService.calcDiscount(null);
});
assertEquals("金额非法", ex.getMessage());
}
@Test
@DisplayName("金额-1,负数抛出异常")
void testAmountNegative(){
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, ()->{
discountService.calcDiscount(-1);
});
assertEquals("金额非法", ex.getMessage());
}
}
补充面试小考点(BigDecimal 坑)
不要写 assertEquals(new BigDecimal(2250), result),推荐字符串构造: new BigDecimal("2250"),因为数字构造会有浮点精度问题。
追加提问
Q:这个 DiscountService 为什么不需要 @Mock、@InjectMocks?
A:DiscountService 没有依赖任何外部对象(DAO / 远程接口),是纯工具类。直接 new 真实对象测试就足够,不需要 mock。