Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战

Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战

文章目录

  • [Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战](#Java 单元测试怎么写:JUnit 5、Mockito 与 Spring Boot 方法级实战)
    • [1. 先回答核心问题:测试的对象是方法承诺,不是代码行](#1. 先回答核心问题:测试的对象是方法承诺,不是代码行)
    • [2. 从业务方法到测试用例:五步闭环](#2. 从业务方法到测试用例:五步闭环)
      • [2.1 第一步:写方法契约](#2.1 第一步:写方法契约)
      • [2.2 第二步:列场景矩阵](#2.2 第二步:列场景矩阵)
      • [2.3 第三步:只隔离真正的边界](#2.3 第三步:只隔离真正的边界)
      • [2.4 第四步:一个测试只执行一个业务动作](#2.4 第四步:一个测试只执行一个业务动作)
      • [2.5 第五步:断言结果,再断言必要交互](#2.5 第五步:断言结果,再断言必要交互)
    • [3. JUnit 5 方法和注解:每一个放在哪里、解决什么问题](#3. JUnit 5 方法和注解:每一个放在哪里、解决什么问题)
      • [3.1 生命周期方法](#3.1 生命周期方法)
      • [3.2 测试、分组与跳过](#3.2 测试、分组与跳过)
      • [3.3 参数化测试](#3.3 参数化测试)
      • [3.4 JUnit 断言方法](#3.4 JUnit 断言方法)
    • [4. AssertJ:让断言直接表达业务结果](#4. AssertJ:让断言直接表达业务结果)
    • [5. Mockito:按方法解释每个 API 的职责](#5. Mockito:按方法解释每个 API 的职责)
      • [5.1 初始化和替身](#5.1 初始化和替身)
      • [5.2 配置依赖行为:`when`、`thenReturn`、`thenThrow`](#5.2 配置依赖行为:whenthenReturnthenThrow)
      • [5.3 验证必要交互:`verify`、`never`、`times`](#5.3 验证必要交互:verifynevertimes)
      • [5.4 参数和顺序:`eq`、`ArgumentCaptor`、`InOrder`](#5.4 参数和顺序:eqArgumentCaptorInOrder)
    • [6. 完整方法级示例:从生产方法到三个测试](#6. 完整方法级示例:从生产方法到三个测试)
      • [6.1 被测生产代码](#6.1 被测生产代码)
      • [6.2 测试类与成功路径](#6.2 测试类与成功路径)
      • [6.3 失败路径:订单不存在和重复支付](#6.3 失败路径:订单不存在和重复支付)
      • [6.4 依赖抛错时怎么写](#6.4 依赖抛错时怎么写)
    • [7. 不同 Java 类应选择什么测试方法](#7. 不同 Java 类应选择什么测试方法)
      • [7.1 工具类、解析器:不用 Spring,直接测输入输出](#7.1 工具类、解析器:不用 Spring,直接测输入输出)
      • [7.2 Service/Engine:结果优先,交互补充](#7.2 Service/Engine:结果优先,交互补充)
      • [7.3 Controller:直接调用、MockMvc、`@WebMvcTest` 分层](#7.3 Controller:直接调用、MockMvc、@WebMvcTest 分层)
      • [7.4 DAO/Mapper:文本契约和真实数据库分开](#7.4 DAO/Mapper:文本契约和真实数据库分开)
      • [7.5 异步与重试:验证状态机,不要靠大超时](#7.5 异步与重试:验证状态机,不要靠大超时)
    • [8. 项目源码对应关系:引用位置说明了什么](#8. 项目源码对应关系:引用位置说明了什么)
    • [9. 常见错误:为什么测试会绿却不可信](#9. 常见错误:为什么测试会绿却不可信)
    • [10. 执行命令、验证边界与排错](#10. 执行命令、验证边界与排错)
    • [11. 最终判断:一份测试是否值得信任](#11. 最终判断:一份测试是否值得信任)
    • [附录:测试 API 速查表](#附录:测试 API 速查表)

内容摘要:本文解决"Java 中到底怎样为一个方法写出可信测试"这一问题。以 JUnit 5、AssertJ、Mockito 和 Spring Boot 为主线,从方法契约、场景矩阵、测试夹具到断言与依赖隔离,给出可照抄的 OrderService.pay 完整示例,并解释每个注解、API、断言和验证方法证明了什么。文中同时说明纯单元测试、Web 层测试、Mapper 契约测试和集成测试的边界,避免把"调用过"误当成"行为正确"。

1. 先回答核心问题:测试的对象是方法承诺,不是代码行

写测试前先把方法说成一句可验证的话:在什么前置条件下,输入什么,应该得到什么结果,并且哪些副作用必须发生或绝不能发生。

例如:

OrderService.pay(订单号) 只有在订单存在且状态为 PENDING 时才允许扣款;扣款成功后保存 PAID 状态并返回已支付订单;订单不存在、状态不允许或扣款失败时,不得保存错误状态。

这句话比"覆盖 pay 方法所有分支"更有用,因为它直接决定测试场景和断言。一个测试只证明它实际断言的行为,不能因为执行过某行代码就推导出生产可用。

本文使用 Java 17、Maven、JUnit Jupiter 5、Mockito 和 AssertJ 说明写法。示例代码是独立示意,未声称属于当前项目源码;项目中的真实引用位置放在"项目源码对应关系"一节。

测试类型 被测边界 常用入口 能证明什么 不能证明什么
纯单元测试 一个类的规则 直接 new 被测类 输入、输出、异常和状态规则 Spring 装配、真实 SQL、网络
Mockito 单元测试 一个类及其外部协作者 @Mock、构造器注入 规则与必要交互 Mock 与真实实现的一致性
Web 层测试 Controller、路由、安全过滤器 @WebMvcTestMockMvc HTTP 状态、JSON、权限 真实数据库和外部服务
集成测试 多个真实组件 @SpringBootTest、Testcontainers 装配、SQL、事务、连接契约 生产规模、真实流量和部署拓扑
手工/端到端验证 指定环境完整链路 *ManualIT、真实服务 真实配置下的结果 快速、稳定、可重复的回归

2. 从业务方法到测试用例:五步闭环

2.1 第一步:写方法契约

把返回值、副作用和失败语义列出来:

契约项 pay 示例
输入 orderId,不能为空且必须指向订单
正常结果 返回状态为 PAID 的订单
必要副作用 调用支付网关;保存 PAID 订单
禁止副作用 找不到订单或状态不允许时不得扣款、不得保存
异常 订单不存在抛 IllegalArgumentException;状态不允许抛 IllegalStateException
不变量 只有 PENDING 才能进入扣款流程

2.2 第二步:列场景矩阵

不要先写代码再猜覆盖率。先把等价类和边界列出:

场景 前置数据 预期结果 交互断言
成功支付 订单存在且 PENDING 返回 PAID 扣款一次、保存一次
订单不存在 Repository 返回空 抛订单不存在异常 不扣款、不保存
重复支付 状态为 PAID 抛状态异常 不再次扣款
网关失败 订单为 PENDING,网关抛异常 异常向上抛出 不保存 PAID
边界输入 null、非法 ID 明确拒绝 不访问不必要依赖

2.3 第三步:只隔离真正的边界

Repository、支付网关、HTTP 客户端、时钟、消息队列属于不稳定或有副作用的边界,可以替换;Order 这样的值对象应该使用真实实例,否则测试只是在配置 Mock 的 getter 返回值。

2.4 第四步:一个测试只执行一个业务动作

每个测试通常只有一次 Act 调用,例如一次 service.pay(1L)。多个 when 是准备条件,不是多个被测动作。若一个方法包含注册、扣费、发货三个独立规则,应拆成多个测试或提取协作者。

2.5 第五步:断言结果,再断言必要交互

结果断言回答"用户得到什么";交互断言回答"跨边界动作是否按契约发生"。只写 verify(repository).save(any()) 不能证明保存的是正确状态;只写 isNotNull() 也不能证明字段正确。

3. JUnit 5 方法和注解:每一个放在哪里、解决什么问题

3.1 生命周期方法

java 复制代码
@BeforeEach
void setUp() {
    // 每个测试前重新创建 fixture,避免测试之间共享可变状态
}

@AfterEach
void tearDown() {
    // 关闭 MockWebServer、恢复系统属性等资源
}

@BeforeAll
static void beforeAll() {
    // 整个测试类只执行一次;默认必须是 static
}

@AfterAll
static void afterAll() {
    // 整个测试类结束后清理一次
}

@BeforeEach 适合构造每个场景自己的对象;不要在这里创建会被多个测试修改的全局订单。@BeforeAll 适合昂贵且只读的资源,但全局状态会引入并行测试风险。

3.2 测试、分组与跳过

  • @Test:标记一个独立测试方法。
  • @DisplayName("...中文业务描述..."):让报告直接显示业务规则。
  • @Nested:按"订单不存在""状态不允许"等前置条件分组,不能替代独立 fixture。
  • @Tag("unit")@Tag("integration"):分类后还要在 Maven/CI 中配置筛选才会生效。
  • @Disabled("原因"):临时跳过必须写原因,不能用来隐藏长期失败。

3.3 参数化测试

java 复制代码
@ParameterizedTest(name = "输入 {0} 应被拒绝")
@NullSource
@EmptySource
@ValueSource(strings = {" ", "\t"})
void blankSkuIsRejected(String value) {
    assertThatThrownBy(() -> parser.parse(value))
        .isInstanceOf(IllegalArgumentException.class);
}

@ValueSource 适合简单字面量;@CsvSource 适合多个简单参数;@MethodSource 适合对象、集合和复杂场景;@NullSource@EmptySource 不等价,空白字符串仍需单独加入。参数化测试的每组输入都必须能独立解释失败原因。

3.4 JUnit 断言方法

方法 用法 它真正证明什么
assertEquals(expected, actual) 值相等 结果值符合契约
assertTrue / assertFalse 布尔规则 条件成立或不成立
assertNull / assertNotNull 缺失/存在 只能证明存在性,不能证明内容正确
assertThrows(Type.class, executable) 异常类型 非法输入走到指定失败出口
assertDoesNotThrow 合法输入不抛异常 仍需继续断言返回内容
assertAll 一次聚合多个相关字段 同一个结果的多个属性同时满足
assertTimeout 纯内存、可控代码的时间上限 本地逻辑没有超出预算,不能替代 HTTP 超时测试

实际项目中 AssertJ 的 assertThat 更常见;JUnit 原生断言与 AssertJ 可以混用,但同一测试最好保持风格一致。

4. AssertJ:让断言直接表达业务结果

java 复制代码
assertThat(result)
    .isNotNull()
    .extracting(Order::status)
    .isEqualTo("PAID");

assertThat(orders)
    .hasSize(2)
    .extracting(Order::id)
    .containsExactly(1L, 2L);

assertThatThrownBy(() -> service.pay(9L))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessage("订单不存在");

containsExactly 同时检查元素和顺序;如果顺序不是业务契约,应使用 containsExactlyInAnyOrderhasSize(2) 不能替代元素内容检查;isNotNull() 不能替代状态、金额和权限断言。异常 Lambda 内只放被测调用,否则 fixture 自己抛错也可能被误判为测试通过。

5. Mockito:按方法解释每个 API 的职责

5.1 初始化和替身

java 复制代码
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    private OrderRepository repository;

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService service;
}
  • @ExtendWith(MockitoExtension.class):让 JUnit 5 初始化 Mockito 注解。
  • @Mock:创建依赖替身;不要 Mock 被测对象本身。
  • @InjectMocks:按构造器、Setter 或字段把替身注入被测对象。构造器注入最容易发现缺失依赖。
  • mock(Type.class):需要在单个测试临时创建替身时使用。

5.2 配置依赖行为:whenthenReturnthenThrow

java 复制代码
when(repository.findById(1L)).thenReturn(Optional.of(pending));
when(repository.findById(9L)).thenReturn(Optional.empty());
when(paymentGateway.charge(7L, amount))
    .thenThrow(new PaymentException("网关拒绝"));

这些语句只建立 Arrange 条件,不证明调用发生。thenReturn 用于非异常返回;thenThrow 用于非 void 方法抛异常。void 方法要用 doThrow(...).when(mock).method(...)

5.3 验证必要交互:verifynevertimes

java 复制代码
verify(paymentGateway).charge(7L, amount);       // 默认恰好一次
verify(repository, times(1)).save(any(Order.class));
verify(paymentGateway, never()).charge(anyLong(), any());

verify 应只验证业务不可替代的边界动作:扣费、写状态、发布事件、禁止路径不产生副作用。times(1) 是默认值,不必到处显式写。never() 用于证明失败或无权限路径没有扣费、写库等危险副作用。

5.4 参数和顺序:eqArgumentCaptorInOrder

java 复制代码
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
verify(repository).save(captor.capture());
assertThat(captor.getValue().status()).isEqualTo("PAID");

InOrder order = inOrder(paymentGateway, repository);
order.verify(paymentGateway).charge(7L, amount);
order.verify(repository).save(any(Order.class));

一个调用中只要使用了 matcher(如 any()),其他参数也应使用 matcher(如 eq(7L)),否则 Mockito 会报参数匹配错误。ArgumentCaptor 适合检查跨边界写出的业务对象;不要捕获每个内部临时值。只有先扣款再保存这一顺序构成一致性契约时才使用 InOrder

timeout() 只适合确实异步的提交点,例如 verify(worker, timeout(1000)).run();它会引入等待,不能代替可控的 Executor、Latch 或测试时钟。reset()clearInvocations() 通常说明一个测试塞入了太多阶段,优先拆测试。

6. 完整方法级示例:从生产方法到三个测试

以下代码是示意代码,用于展示写法;未在当前文档目录编译。生产代码使用构造器注入,测试因此只需替换 Repository 和支付网关。

6.1 被测生产代码

java 复制代码
package example.order;

import java.math.BigDecimal;

public final class OrderService {
    private final OrderRepository repository;
    private final PaymentGateway paymentGateway;

    public OrderService(OrderRepository repository, PaymentGateway paymentGateway) {
        this.repository = repository;
        this.paymentGateway = paymentGateway;
    }

    public Order pay(Long orderId) {
        Order order = repository.findById(orderId)
            .orElseThrow(() -> new IllegalArgumentException("订单不存在"));
        if (!"PENDING".equals(order.status())) {
            throw new IllegalStateException("订单不处于待支付状态");
        }
        paymentGateway.charge(order.userId(), order.amount());
        return repository.save(order.paid());
    }
}
方法位置 输入/输出 业务规则 失败语义
findById orderIdOrder 订单必须存在 空结果转为"订单不存在"
状态判断 Order.status() 只有 PENDING 可继续 其他状态拒绝
charge 用户、金额 扣款是外部副作用 网关异常向上抛出
save order.paid()Order 扣款后才保存 PAID 保存失败不应伪造成功

6.2 测试类与成功路径

java 复制代码
package example.order;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    private OrderRepository repository;

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService service;

    @Test
    void pay_shouldChargeAndSavePaidOrder_whenOrderIsPending() {
        Order pending = new Order(1L, 7L, new BigDecimal("99.00"), "PENDING");
        when(repository.findById(1L)).thenReturn(Optional.of(pending));
        when(repository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0));

        Order result = service.pay(1L);

        assertThat(result).extracting(Order::status).isEqualTo("PAID");
        verify(paymentGateway).charge(7L, new BigDecimal("99.00"));

        ArgumentCaptor<Order> saved = ArgumentCaptor.forClass(Order.class);
        verify(repository).save(saved.capture());
        assertThat(saved.getValue())
            .extracting(Order::id, Order::status)
            .containsExactly(1L, "PAID");
    }

这段测试逐句对应 AAA:

  1. Arrange :创建真实 Order,并规定 Repository 找到它;thenAnswer 让保存方法返回传入对象,避免为每个状态再造一个无关 Mock。
  2. Act :只调用一次 service.pay(1L)
  3. Assert 结果 :返回订单状态必须是 PAID
  4. Assert 副作用 :支付网关必须收到用户和金额;Repository 保存的对象必须是订单 1 且状态为 PAID

如果实现错误地保存了原订单、金额被改写或状态仍为 PENDING,该测试会失败;这比只写 verify(repository).save(any()) 强得多。

6.3 失败路径:订单不存在和重复支付

java 复制代码
    @Test
    void pay_shouldRejectMissingOrder_withoutChargingOrSaving() {
        when(repository.findById(9L)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> service.pay(9L))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("订单不存在");

        verify(paymentGateway, never()).charge(anyLong(), any());
        verify(repository, never()).save(any(Order.class));
    }

    @Test
    void pay_shouldRejectNonPendingOrder_withoutChargingAgain() {
        Order paid = new Order(1L, 7L, new BigDecimal("99.00"), "PAID");
        when(repository.findById(1L)).thenReturn(Optional.of(paid));

        assertThatThrownBy(() -> service.pay(1L))
            .isInstanceOf(IllegalStateException.class)
            .hasMessage("订单不处于待支付状态");

        verify(paymentGateway, never()).charge(anyLong(), any());
        verify(repository, never()).save(any(Order.class));
    }
}

失败测试的重点不是"异常被抛出"这一个断言,而是同时证明危险副作用没有发生 。如果订单不存在却调用了扣款网关,系统可能出现无法对账的真实损失;因此 never() 是业务断言,不是 Mockito 装饰。

6.4 依赖抛错时怎么写

java 复制代码
@Test
void pay_shouldNotSavePaidOrder_whenPaymentGatewayFails() {
    Order pending = new Order(1L, 7L, new BigDecimal("99.00"), "PENDING");
    when(repository.findById(1L)).thenReturn(Optional.of(pending));
    doThrow(new PaymentException("网关拒绝"))
        .when(paymentGateway)
        .charge(7L, new BigDecimal("99.00"));

    assertThatThrownBy(() -> service.pay(1L))
        .isInstanceOf(PaymentException.class)
        .hasMessage("网关拒绝");

    verify(repository, never()).save(any(Order.class));
}

这里使用 doThrow 是因为 chargevoid 方法。测试证明的是当前方法不会在扣款失败后伪造 PAID 保存;事务回滚、支付幂等和重试策略仍需集成测试或契约测试补充。

7. 不同 Java 类应选择什么测试方法

7.1 工具类、解析器:不用 Spring,直接测输入输出

解析器应使用真实字符串和集合,覆盖空值、空白、重复、最大值、刚越界值以及错误消息。参数化测试可以减少重复,但每一组数据仍要对应一个明确规则。

java 复制代码
@ParameterizedTest
@CsvSource({"200,true", "201,false"})
void acceptsOnlyAtMostTwoHundredItems(int count, boolean expected) {
    assertThat(BatchSkuParser.isAllowedCount(count)).isEqualTo(expected);
}

7.2 Service/Engine:结果优先,交互补充

Mock Mapper、Repository、时钟、远程客户端和事件发布器;不要 Mock 值对象。验证状态迁移、返回值、异常、补偿和禁止路径。事务、锁、Redis TTL、多节点竞争不应被纯 Mockito 测试冒充为已验证。

7.3 Controller:直接调用、MockMvc、@WebMvcTest 分层

直接调用 Controller 适合 DTO 和响应头;MockMvcBuilders.standaloneSetup 适合局部路由;@WebMvcTest 适合 MVC、JSON 和安全切片;@SpringBootTest 才会加载完整上下文,但成本和外部依赖也更高。Controller 测试必须断言 HTTP 状态、错误码、响应字段和安全头,而不是只断言响应对象非空。

7.4 DAO/Mapper:文本契约和真实数据库分开

反射读取 @Select 并断言 LEFT JOINGROUP BY 等结构,只能防止关键 SQL 被误删;它不能证明 MySQL 方言、数据重复、索引或执行计划。真实 SQL 结论要在隔离数据库中运行迁移、插入最小数据并检查结果与 EXPLAIN

7.5 异步与重试:验证状态机,不要靠大超时

优先注入可控 Executor、Clock、Sleeper 或测试队列。若确实需要等待,使用短 timeout 并同时断言最终状态、提交次数和失败补偿。timeout(30_000) 只会让竞态更慢暴露,不能让异步逻辑变可靠。

8. 项目源码对应关系:引用位置说明了什么

以下位置是现有文档中引用的项目证据,读者可按路径核对;它们说明项目采用了哪些测试方式,不代表每个生产边界都已覆盖。

位置 观察到的测试方法 可支持的判断
src/test/java/com/aiimage/service/BatchSkuParserTest.java:9-35 纯 Java、AssertJ、边界输入 解析结果、去重和上限规则可快速回归
src/test/java/com/aiimage/auth/AuthControllerTest.java:25-116 直接调用 Controller、响应头断言 DTO 和安全响应契约可局部验证
src/test/java/com/aiimage/engine/PromptAssemblerTest.java:21-140,183-190 @ExtendWith@Mock@InjectMocks@MethodSource 参数化提示词规则无需启动 Spring
src/test/java/com/aiimage/engine/TaskEngineTest.java:58-147 Mockito、never()、异步提交验证 领取失败或禁止路径不提交任务
src/test/java/com/aiimage/engine/TaskExecutorTest.java:157-254 ArgumentCaptorInOrder、补偿断言 跨边界写入顺序和失败收敛有局部证据
src/test/java/com/aiimage/dao/TaskMapperQueryContractTest.java:10-22 反射读取 SQL 注解 SQL 文本结构有轻量护栏,未证明真实执行
src/test/java/com/aiimage/security/SecurityConfigTest.java:40-118 Spring 安全切片 未认证和 Bearer 请求的局部行为可验证
src/test/java/com/aiimage/service/PromptGenerationDevManualIT.java:15-44 @SpringBootTest、条件启用、真实开发服务 受控手工集成验证,不属于日常快速单测

9. 常见错误:为什么测试会绿却不可信

错误写法 表面现象 修正方式
只断言 isNotNull() 错误对象也能通过 断言状态、字段、集合元素和错误码
只写 verify(save(any())) 任意错误数据都能通过 ArgumentCaptor 检查关键字段
Mock 所有对象 测试只验证 Mock 配置 值对象和规则对象使用真实实例
只测成功分支 线上异常路径未覆盖 增加空、非法、边界、依赖失败、重复调用
一个测试验证多个业务动作 失败定位困难 一个测试一个 Act;相关字段用 assertAll
@SpringBootTest 测所有方法 慢、依赖多、失败难定位 规则用纯单元,Web 用切片,连接用集成
为测私有方法把它改成 public 暴露实现细节 测公共行为;必要时提取协作者
用超长 timeout 等待 偶现失败被掩盖 注入可控调度器、时钟和队列
把集成测试叫单元测试 读者误判覆盖边界 按真实依赖范围命名和执行

10. 执行命令、验证边界与排错

在 Maven 项目模块目录执行:

bash 复制代码
# 运行默认单元测试
mvn test

# 运行指定测试类
mvn -Dtest=BatchSkuParserTest test

# 运行指定测试方法
mvn -Dtest=BatchSkuParserTest#rejectsMoreThanTwoHundredDistinctSkus test

# 仅编译并打包,跳过测试执行
mvn -DskipTests package

mvn test 的成功证据是 Surefire 报告中指定测试实际执行且 failures/errors 为 0;-DskipTests package 只能证明编译和打包链路。测试失败时按顺序检查:断言是否表达业务规则、fixture 是否构造目标状态、Mock matcher 是否过宽、异步/全局状态是否泄漏,最后才判断生产代码。

层次 已证明 未证明 下一步
示例代码 展示了从方法契约到 AAA、结果断言和副作用断言的完整写法 本文目录未编译示例,依赖的 Order、Repository 等接口需补齐 放入 Maven 模块后运行指定测试
项目引用 现有源码路径展示了项目使用的 JUnit、Mockito、AssertJ 和 Spring 测试方式 未由这些引用推出真实数据库、Redis、OSS 或第三方服务可用 在隔离环境追加集成和冒烟验证
构建命令 说明 Maven 如何收窄测试范围 未执行当前项目全量测试,不能宣称全绿 在目标模块执行 mvn test 并保存报告

11. 最终判断:一份测试是否值得信任

读完测试,应该能回答四个问题:给定什么条件?方法承诺什么结果?哪些副作用必须发生或禁止发生?失败时系统如何收敛?如果只能看到"某个方法被调用",却看不到业务结果和失败语义,测试仍然不完整。

推荐的最小落地顺序是:先为公共方法写成功、空值/非法、边界、依赖失败和重复调用场景;再只替换数据库、网络、时钟等边界依赖;最后用 mvn -Dtest=类名#方法名 test 运行并阅读失败报告。随着风险增加,再补 @WebMvcTest、真实数据库集成测试和受控端到端验证。测试层级越宽,能证明的协作越多,但运行成本和环境变量也越多,不能相互替代。

附录:测试 API 速查表

目的 首选 API
初始化 Mockito @ExtendWith(MockitoExtension.class)
创建依赖替身 @Mockmock()
注入被测对象 @InjectMocks 或显式构造器
配置返回值 when(...).thenReturn(...)
配置异常 thenThrow(...)doThrow(...).when(...)
结果断言 AssertJ assertThat(...)
异常断言 assertThrowsassertThatThrownBy
禁止交互 verify(mock, never())
精确参数 eq(...)
捕获写入对象 ArgumentCaptor
验证顺序 InOrder
多组输入 @ParameterizedTest@CsvSource@MethodSource
Web 层 @WebMvcTestMockMvc
集成层 @SpringBootTest

结论:Java 测试用例应从方法契约和场景矩阵出发,用 AAA 组织一次业务动作,用 AssertJ 证明结果,用 Mockito 只隔离边界并验证必要副作用,再用 Spring/数据库集成测试补足单元测试无法证明的运行环境。

相关推荐
CoderYanger2 小时前
前端基础——JavaScript(WebAPI)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
lisin-lee-cooper2 小时前
简单聊聊 Spring 框架源码
java·后端·spring
一木 之林2 小时前
七、一-AI 工程实践、插件化调试与软件交付
java·linux·c++
边境悍匪2 小时前
蜗牛学苑 Java 智能体学习 Day38|Spring AI Alibaba2 思维导图复盘
java·学习·spring
Co_Hui2 小时前
Java 静态内部类、非静态内部类、匿名内部类的区别?
java
Wang's Blog2 小时前
Java框架快速入门: Spring Security+OAuth2之自定义认证过滤器实现JSON登录
java·spring·json
Wang's Blog3 小时前
Java框架快速入门: Spring Security+OAuth2之密码验证规则与自定义注解
java·数据库·spring
一嘴一个橘子3 小时前
java - redis 缓存击穿 - 互斥锁
java
shehuiyuelaiyuehao3 小时前
算法39,位运算,消失的两个数字
java·数据结构·算法