[测试技术] JUnit 6 入门与实战:断言、参数化与 Mockito

原创内容,未获授权禁止转载、转发、抄袭。

Java 项目通常不缺测试框架,真正容易出问题的是:只验证正常返回,不验证异常分支;Mock 了外部依赖,却没有检查错误副作用;测试数量很多,业务规则仍然没有被覆盖。

JUnit 是 Java 生态中最常用的测试框架。本文使用"取消订单"场景,把 JUnit 6、参数化测试、Mockito 和 Maven 集成串起来,重点放在可以直接落地的测试写法。

JUnit 6 能做什么

JUnit 6 由三个部分组成:

模块 作用
JUnit Platform 发现、执行测试,并为 IDE 和构建工具提供统一入口
JUnit Jupiter 编写 JUnit 测试的注解、断言、参数化和扩展模型
JUnit Vintage 兼容运行 JUnit 3、JUnit 4 测试,仅建议迁移期间临时使用

截至 2026 年 7 月 20 日,JUnit 最新正式版为 6.1.2,发布于 2026 年 7 月 12 日。该版本中的 Platform、Jupiter 和 Vintage 统一使用 6.1.2 版本号,运行时要求 Java 17 或更高版本。

使用 Maven 集成 JUnit

示例目录如下:

text 复制代码
junit-demo/
├── pom.xml
└── src/
    ├── main/java/demo/
    │   └── OrderService.java
    └── test/java/demo/
        ├── OrderServiceTest.java
        └── OrderStatusTest.java

pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>demo</groupId>
    <artifactId>junit-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.release>17</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <junit.version>6.1.2</junit.version>
        <mockito.version>5.23.0</mockito.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.junit</groupId>
                <artifactId>junit-bom</artifactId>
                <version>${junit.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-junit-jupiter</artifactId>
            <version>${mockito.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.5</version>
                <configuration>
                    <argLine>
                        -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar
                    </argLine>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

JUnit BOM 只统一 JUnit 各模块的版本,因此 Mockito 仍需单独指定版本。junit-jupiter 已包含编写和运行 Jupiter 测试所需的组件。JUnit 6 要求 Maven Surefire 或 Failsafe 至少为 3.0.0,这里使用官方文档示例中的 3.5.5

Mockito 5 默认使用 inline mock maker。Surefire 中显式加载 mockito-core Java Agent,可以避免受限环境无法动态附加 Agent,并适配新版本 JDK 对 Agent 加载的限制。项目如果同时使用 JaCoCo,需要合并两者的 argLine,不能相互覆盖。

执行前先运行 mvn -version,确认 Maven 实际使用的是 Java 17+,因为终端的 java -version 与 Maven 使用的 JAVA_HOME 可能不同。项目中还应固定依赖和插件版本,避免开发机与 CI 因自动升级出现不一致结果。

准备被测代码

示例业务规则:

  • 订单不存在时取消失败
  • 只有待支付订单可以取消
  • 取消订单需要先释放库存
  • 库存释放成功后才能保存取消状态
  • 订单保存失败时需要回补库存
  • 已支付订单不能取消,也不能释放库存

创建 src/main/java/demo/OrderService.java

java 复制代码
package demo;

import java.util.Optional;

enum OrderStatus {
    PENDING,
    PAID,
    CANCELLED;

    boolean canCancel() {
        return this == PENDING;
    }
}

record Order(long id, long productId, int quantity, OrderStatus status) {
    Order withStatus(OrderStatus newStatus) {
        return new Order(id, productId, quantity, newStatus);
    }
}

interface OrderRepository {
    Optional<Order> findById(long orderId);

    void save(Order order);
}

interface InventoryService {
    void release(long productId, int quantity);

    void reserve(long productId, int quantity);
}

public class OrderService {
    private final OrderRepository repository;
    private final InventoryService inventory;

    public OrderService(OrderRepository repository, InventoryService inventory) {
        this.repository = repository;
        this.inventory = inventory;
    }

    public Order cancel(long orderId) {
        Order order = repository.findById(orderId)
                .orElseThrow(() -> new IllegalArgumentException("订单不存在"));

        if (!order.status().canCancel()) {
            throw new IllegalStateException("当前状态不允许取消");
        }

        inventory.release(order.productId(), order.quantity());
        Order cancelled = order.withStatus(OrderStatus.CANCELLED);
        try {
            repository.save(cancelled);
        } catch (RuntimeException error) {
            inventory.reserve(order.productId(), order.quantity());
            throw error;
        }
        return cancelled;
    }
}

订单仓储和库存服务都通过构造方法注入。单元测试可以用 Mockito 替换数据库和远程库存服务,只验证 OrderService 的业务规则。示例使用同步回补展示失败分支;真实项目还需要考虑回补失败、重复执行和跨系统一致性,通常通过幂等补偿、事务消息等方案处理。

常用注解与断言

JUnit Jupiter 常用注解:

注解 作用
@Test 标记普通测试方法
@BeforeEach@AfterEach 每条测试前后执行
@BeforeAll@AfterAll 当前测试类全部测试前后执行
@ParameterizedTest 标记参数化测试
@Nested 按业务场景组织嵌套测试类
@Tag 给测试分类,便于按组执行
@Disabled 临时禁用测试,应注明原因并尽快恢复
@TempDir 为文件测试创建并自动清理临时目录

常用断言示例:

java 复制代码
assertEquals(OrderStatus.CANCELLED, actual.status());

assertAll(
        () -> assertEquals(1001L, actual.id()),
        () -> assertEquals(2, actual.quantity())
);

IllegalStateException error = assertThrows(
        IllegalStateException.class,
        () -> service.cancel(1002L)
);
assertEquals("当前状态不允许取消", error.getMessage());

Order result = assertTimeout(
        Duration.ofSeconds(2),
        () -> service.cancel(1001L)
);
assertEquals(OrderStatus.CANCELLED, result.status());

断言的参数顺序是"期望值在前,实际值在后"。assertAll() 会执行组内全部断言,适合一次检查同一对象的多个字段;assertThrows() 会返回捕获到的异常,可以继续验证异常信息。它允许抛出指定异常的子类,需要严格匹配异常类型时使用 assertThrowsExactly()

assertTimeout() 在当前线程执行代码,超时后会等代码执行完再报告失败。阈值应为 CI 波动留出余量,它适合发现异常阻塞,不能代替性能测试。assertTimeoutPreemptively() 会在另一个线程中执行,并在超时后尝试中断任务,可能丢失事务、认证等 ThreadLocal 上下文,涉及 Spring 事务时不要随意使用。

Mockito 实战:不仅验证结果,还要验证副作用

创建 src/test/java/demo/OrderServiceTest.java

java 复制代码
package demo;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

import java.util.Optional;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@Tag("unit")
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    OrderRepository repository;

    @Mock
    InventoryService inventory;

    @Captor
    ArgumentCaptor<Order> orderCaptor;

    OrderService service;

    @BeforeEach
    void setUp() {
        service = new OrderService(repository, inventory);
    }

    @Test
    void cancelsPendingOrderAfterReleasingInventory() {
        Order pending = new Order(1001L, 2001L, 2, OrderStatus.PENDING);
        when(repository.findById(1001L)).thenReturn(Optional.of(pending));

        Order actual = service.cancel(1001L);

        assertAll(
                () -> assertEquals(1001L, actual.id()),
                () -> assertEquals(OrderStatus.CANCELLED, actual.status())
        );

        InOrder calls = inOrder(inventory, repository);
        calls.verify(inventory).release(2001L, 2);
        calls.verify(repository).save(orderCaptor.capture());
        Order saved = orderCaptor.getValue();
        assertAll(
                () -> assertEquals(1001L, saved.id()),
                () -> assertEquals(2001L, saved.productId()),
                () -> assertEquals(2, saved.quantity()),
                () -> assertEquals(OrderStatus.CANCELLED, saved.status())
        );
    }

    @Test
    void rejectsPaidOrderWithoutReleasingInventory() {
        Order paid = new Order(1002L, 2001L, 1, OrderStatus.PAID);
        when(repository.findById(1002L)).thenReturn(Optional.of(paid));

        IllegalStateException error = assertThrows(
                IllegalStateException.class,
                () -> service.cancel(1002L)
        );

        assertEquals("当前状态不允许取消", error.getMessage());
        verifyNoInteractions(inventory);
        verify(repository, never()).save(any());
    }

    @Test
    void doesNotSaveOrderWhenInventoryReleaseFails() {
        Order pending = new Order(1003L, 2002L, 3, OrderStatus.PENDING);
        when(repository.findById(1003L)).thenReturn(Optional.of(pending));
        doThrow(new IllegalStateException("库存服务不可用"))
                .when(inventory).release(2002L, 3);

        IllegalStateException error = assertThrows(
                IllegalStateException.class,
                () -> service.cancel(1003L)
        );

        assertEquals("库存服务不可用", error.getMessage());
        verify(inventory).release(2002L, 3);
        verify(repository, never()).save(any());
    }

    @Test
    void restoresInventoryWhenSavingCancelledOrderFails() {
        Order pending = new Order(1004L, 2003L, 1, OrderStatus.PENDING);
        when(repository.findById(1004L)).thenReturn(Optional.of(pending));
        doThrow(new IllegalStateException("数据库不可用"))
                .when(repository).save(any());

        IllegalStateException error = assertThrows(
                IllegalStateException.class,
                () -> service.cancel(1004L)
        );

        assertEquals("数据库不可用", error.getMessage());
        InOrder calls = inOrder(inventory, repository);
        calls.verify(inventory).release(2003L, 1);
        calls.verify(repository).save(any());
        calls.verify(inventory).reserve(2003L, 1);
    }

    @Test
    void rejectsMissingOrderWithoutReleasingInventory() {
        when(repository.findById(9999L)).thenReturn(Optional.empty());

        IllegalArgumentException error = assertThrows(
                IllegalArgumentException.class,
                () -> service.cancel(9999L)
        );

        assertEquals("订单不存在", error.getMessage());
        verifyNoInteractions(inventory);
        verify(repository, never()).save(any());
    }
}

五条测试分别覆盖正常取消、状态拦截、库存异常、订单保存异常和订单不存在。这里有四个值得保留的做法:

  • 使用 ArgumentCaptor 检查真正写入仓储的订单状态
  • 使用 never()verifyNoInteractions() 验证失败路径没有错误副作用
  • 保存失败后验证库存回补,避免只检查抛出的异常
  • 只有"先释放库存,后保存状态"属于业务规则,因此才使用 InOrder

不要对每个内部方法都执行 verify()。Mock 验证越贴近实现细节,代码重构时产生的无效失败越多。数据库、HTTP/RPC、消息队列、第三方支付等系统边界适合 Mock;测试仓储 SQL 时则应该连接可控数据库,不要 Mock 自己要验证的对象。

参数化测试:把规则和数据分开

订单状态不断增加时,为每个状态复制一条测试很难维护。创建 src/test/java/demo/OrderStatusTest.java

java 复制代码
package demo;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

@Tag("unit")
class OrderStatusTest {
    @ParameterizedTest(name = "[{index}] status={0}, expected={1}")
    @CsvSource({
            "PENDING, true",
            "PAID, false",
            "CANCELLED, false"
    })
    void onlyPendingStatusCanBeCancelled(OrderStatus status, boolean expected) {
        assertEquals(expected, status.canCancel());
    }
}

参数化测试只保留一份执行逻辑,测试数据直接表达"输入状态与预期结果"。不要在测试中复制生产代码的判断逻辑,否则生产代码和测试可能一起写错。

常用数据源:

数据源 适用场景
@ValueSource 单个简单参数
@CsvSource 少量多字段规则表
@CsvFileSource 从 CSV 文件读取较多数据
@MethodSource 复杂对象、动态数据和边界组合
@NullSource@EmptySource 空值和空字符串边界

参数化测试适合规则表和边界组合,不适合把完全不同的业务场景塞进同一方法。失败后必须能从测试名称和参数中直接看出是哪组数据出错。

测试生命周期与独立性

JUnit 默认使用 PER_METHOD 生命周期:每条测试都会创建新的测试类实例,实例字段不会在测试间共享。这也是示例在 @BeforeEach 中重新创建 OrderService 的原因。

使用 @TestInstance(TestInstance.Lifecycle.PER_CLASS) 后,同一个实例会执行类内全部测试,@BeforeAll@AfterAll 也可以写成非静态方法。但共享可变字段很容易造成顺序依赖,只有初始化成本很高且能可靠清理的资源才考虑使用。

排查测试间依赖时,可以把 Jupiter 的方法顺序改为随机,并连续执行多次:

bash 复制代码
mvn '-Djunit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random' test

引号用于防止 Shell 展开类名中的 $。随机执行只适合暴露顺序依赖,不能用自动重跑掩盖不稳定测试。时间、随机数和外部资源应通过可控依赖注入。

执行测试与生成报告

bash 复制代码
# 执行全部测试
mvn test

# 只执行一个测试类
mvn -Dtest=OrderServiceTest test

# 只执行一个测试方法
mvn -Dtest=OrderServiceTest#cancelsPendingOrderAfterReleasingInventory test

# 只执行带 unit 标签的测试
mvn -Dgroups=unit test

本文示例在 Java 17.0.19、Maven 3.9.11 环境中实际执行结果:

text 复制代码
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0

Maven Surefire 默认会发现名称匹配 *Test*Tests*TestCase 等模式的测试类,结果保存在 target/surefire-reports。CI 中可以直接归档该目录中的 XML 报告。

bash 复制代码
mvn -B -Dgroups=unit verify

单元测试应在每次提交时执行;连接数据库、消息队列等资源的集成测试建议单独使用 Failsafe,并通过标签或目录与单元测试分开,避免本地和 CI 的执行范围不一致。

从 JUnit 4 迁移到 JUnit 6

JUnit 4 和 Jupiter 的注解包不同,不能只改版本号。常见替换关系如下:

JUnit 4 JUnit Jupiter
org.junit.Test org.junit.jupiter.api.Test
@Before@After @BeforeEach@AfterEach
@BeforeClass@AfterClass @BeforeAll@AfterAll
@Ignore @Disabled
@Test(expected=...) assertThrows()
@RunWith(Parameterized.class) @ParameterizedClass + 参数源
@RunWith(MockitoJUnitRunner.class) @ExtendWith(MockitoExtension.class)
@Rule TemporaryFolder @TempDir

JUnit 4 的 Parameterized Runner 是类级参数化,对应 JUnit 6 的 @ParameterizedClass;只有单个方法需要多组数据时才使用 @ParameterizedTest。JUnit 6.1.2 中 @ParameterizedClass 仍是实验性功能,升级时需要关注兼容性。

存量项目可以临时引入 Vintage Engine,让 JUnit 4 测试继续在 Platform 上运行,再按模块迁移。迁移时应重点检查 org.junit.* 旧导入、Runner 和 Rule,不能只替换注解名称。Vintage 已被标记为弃用,不适合作为长期方案;迁移完成后应删除 Vintage 和 JUnit 4 依赖,防止新测试继续使用旧 API。

常见问题

问题 处理方式
JUnit 6 测试无法启动 检查运行时是否为 Java 17+,并确认 IDE、Maven 和 CI 使用同一 JDK
测试编译成功但没有执行 检查 Surefire 版本、测试类命名和 @Test 的导入包
@BeforeAll 不能访问实例字段 默认生命周期下方法应为 static,或明确使用 PER_CLASS
Mockito Mock 没有初始化 添加 @ExtendWith(MockitoExtension.class)
异常测试只写了 assertThrows 继续检查异常信息,并验证失败路径没有写库、发消息等副作用
参数化测试失败难定位 使用 name 展示索引和关键参数,避免一条数据承载多个场景
测试依赖执行顺序 清理共享状态,并随机顺序重复执行定位问题
失败测试长期使用 @Disabled 记录原因和恢复条件,不能用禁用代替修复

工程实践建议

  • 测试名写清"场景 + 期望结果"
  • 按 Arrange、Act、Assert 组织测试代码
  • 正常、边界、异常和错误副作用都要覆盖
  • 参数化数据直接写预期,不复制生产算法
  • 只在系统边界使用 Mock,避免验证无关实现细节
  • 每条测试独立运行,不依赖顺序和其他测试结果
  • 单元测试与集成测试分组执行,CI 保留 XML 报告
  • 覆盖率用于发现遗漏,不能替代有效断言和业务场景设计

总结

JUnit 6 的基础使用并不复杂:使用 Jupiter 编写断言,使用参数化测试覆盖规则表,使用 Mockito 隔离外部依赖,再通过 Maven Surefire 接入 CI。

不要为了覆盖率堆测试。能稳定复现问题、准确验证业务规则、失败后快速定位原因,才是一条真正有价值的单元测试。

相关推荐
吴声子夜歌6 小时前
Redis 3.x——集群故障转移
java·数据库·redis·集群
名不经传的养虾人6 小时前
从0到1:企业级AI项目迭代日记 Vol.71|系统显示正常,但实际上什么都没发生
数据库·人工智能·ai编程·ai工作流·企业ai
尽兴-7 小时前
企业数据库选型与演进:Oracle、达梦、MySQL 与分库分表实践
数据库·mysql·oracle·分库分表
空中湖7 小时前
Spring AI Function Calling 完全指南:让 AI 查数据库、调 API、发通知
数据库·人工智能·spring
金伟API10248 小时前
常见的SQL面试题:经典50例
数据库·人工智能·笔记·sql·学习
谜之锋9 小时前
银河麒麟 Debian 系统离线deb包安装Mariadb 数据库指南
数据库·debian·mariadb
Leighteen9 小时前
MongoDB 文档模型设计:从关系型思维到文档型思维的转变
数据库·mongodb
CodexDave9 小时前
数据库连接池耗尽:排查顺序与三层兜底
服务器·前端·数据库·git·云原生·容器·kubernetes
播播资源10 小时前
智能路由(AI Router):用“虚拟模型”撬动无限算力,从此告别手动切换
数据库