一文学会单元测试之----Junit

JUnit 简介

JUnit 是 Java 生态最主流的单元测试框架,用来对单个方法 / 类做自动化测试,把测试代码写在项目里,一键运行,验证逻辑是否符合预期。

现在主流版本:JUnit 5(Jupiter),老项目多见 JUnit 4。

JUnit 5 整体结构

JUnit5 由三部分组成:

  1. JUnit Jupiter :写测试用例(API,@Test 等注解)
  2. JUnit Platform:测试运行引擎,IDE/Maven/Gradle 底层依赖
  3. 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 重点区别

  1. 包路径:JUnit4 org.junit;JUnit5 org.junit.jupiter.api
  2. 注解改名:@Before@BeforeEach@BeforeClass@BeforeAll
  3. 断言类:JUnit4 Assert;JUnit5 Assertions
  4. 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:

  1. @BeforeAll:所有测试方法前执行一次,static
  2. @BeforeEach:每个 @Test 前执行
  3. @Test:测试方法
  4. @AfterEach:每个 @Test 后执行
  5. @AfterAll:所有测试结束后执行一次,static

注意:测试方法不能是 private,返回值 void。

Q2:JUnit5 和 JUnit4 的主要区别?

A:

  1. 包名:JUnit4 org.junit;JUnit5 org.junit.jupiter.api
  2. 注解改名:@Before@BeforeEach@BeforeClass@BeforeAll
  3. JUnit5 支持 Java8 lambda,支持组合断言、参数化测试
  4. JUnit5 模块化,平台 + jupiter+vintage,vintage 用来跑 JUnit4 旧用例
  5. 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,可在测试生命周期各个节点插入自定义逻辑。 常用扩展点接口:

  • BeforeAllCallback
  • BeforeEachCallback
  • AfterEachCallback
  • AfterAllCallback
  • TestExecutionExceptionHandler:捕获测试异常

示例:自定义扩展,打印测试开始 / 结束日志

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("执行测试逻辑");
    }
}

常用场景

  1. 自动初始化 / 清理资源(连接池)
  2. 全局捕获测试异常,自定义告警
  3. 自定义注解,实现通用能力(比如自动生成测试数据)
  4. 埋点统计测试耗时

延伸

  1. Spy 和 Mock 坑点 :spy 对象调用真实方法,如果方法里面抛异常,即使你打桩也要注意;spy 打桩推荐用 doReturn().when(),不要 when().thenReturn()
java 复制代码
UserDao spyDao = spy(UserDao.class);
// 推荐
doReturn("test").when(spyDao).getUserNameById(1L);
  1. 单元测试、集成测试区别
  • 单元测试:测试单个类 / 方法,mock 所有外部依赖,速度快,开发写
  • 集成测试:多个组件一起跑,使用真实 DB / 中间件,验证交互
  1. 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 个场景:

  1. 订单金额 1500,返回 true(大额订单)
  2. 订单金额 800,返回 false(普通订单)
  3. dao 返回 null,返回 false
  4. 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。

相关推荐
FungLeo2 天前
成为全栈·Node 后端篇·后端测试策略:单元、集成与测试数据库
单元测试·node.js·集成测试·测试策略·成为全栈·测试数据库
yume_sibai2 天前
09-Rust 测试与质量保证(单元测试 + 集成测试 + Mock + Benchmark + Fuzzing + CI/CD)
rust·单元测试·集成测试
ChampaignWolf3 天前
Joule Unit Test 深度集成:ABAP 单元测试的 AI 六件套全解析
人工智能·单元测试·sap·abap·joule·单元测试ai
川石课堂软件测试3 天前
涨薪技术|Prometheus之HTTP API中使用PromQL
网络协议·测试工具·jmeter·http·单元测试·postman·prometheus
半亩码田4 天前
C#转Python第4.5篇:单元测试:pytest vs xUnit/NUnit
python·单元测试·c#
AINative软件工程4 天前
LLM 应用的测试替身工程实践:用 Fake/Stub/Mock 让 AI 代码真正跑起来 CI
单元测试·llm·ai编程
Java小白笔记5 天前
Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战
java·junit·单元测试
sanjiaomao3336 天前
从论文公式到Python实现:用单元测试校验滑动平均算法
python·算法·单元测试