S05-12 高级特性-单元测试
[TOC]
单元测试
在软件开发中,单元测试(Unit Testing) 是保障代码质量、重构安全以及减少 Bug 的最基础也是最重要的一环。在 Java 生态中,单元测试有着非常成熟的体系和工具链。
什么是单元测试
什么是单元测试:
单元测试是指对软件中的最小可测试单元进行检查和验证。在 Java 中,这个“最小单元”通常指的是方法(Method) 或 类(Class)。
其核心思想是:隔离。将代码的一部分从其他部分中隔离出来,证明这部分逻辑是正确的,不依赖于真实的数据库、网络或其他外部系统。
单元测试的好处:
- 尽早发现 Bug: 在开发阶段就能捕捉到逻辑错误,修复成本极低。
- 重构的底气: 有了完善的测试覆盖率,修改老代码时就不怕引入新的 Bug。
- 代码即文档: 优秀的测试用例展示了方法应该如何被调用,以及预期的返回结果。
- 驱动设计(TDD): 促使开发者写出高内聚、低耦合、易于测试的代码。
核心框架
Java 单元测试的核心框架:
在 Java 中,进行单元测试通常依赖以下两大“神器”的组合:
JUnit: Java 最核心的测试框架,用于控制测试的生命周期和执行断言。目前主流是 JUnit 5 (Jupiter)。
Mockito: Java 最流行的 Mock(模拟)框架,用于模拟当前测试对象所依赖的其他组件(如数据库访问层、外部 API),从而实现真正的“隔离”。
注意事项
注意事项:
- 被修饰的单元测试方法不能有参数。
- 被修饰的单元测试方法不能有返回值。
- 被修饰的单元测试方法不能是静态方法。
单元测试的最佳实践
单元测试的最佳实践:
编写优秀的单元测试和编写优秀的业务代码一样重要。以下是一些业界公认的准则:
遵循 AAA 模式 (Arrange, Act, Assert):
- Arrange(准备): 初始化测试数据和 Mock 对象。
- Act(执行): 调用被测试的方法。
- Assert(断言): 验证返回结果或对象状态是否符合预期。
遵循 FIRST 原则:
- Fast (快速): 测试执行应该极快,毫秒级别。
- Isolated (隔离): 测试之间不能相互影响,不能依赖外部环境(如真实的 DB、网络)。
- Repeatable (可重复): 在任何环境、任何时间运行,结果都应该一致。
- Self-validating (自验证): 测试应该自动判断成功或失败,不需要人工检查控制台输出。
- Timely (及时): 测试应该在代码编写的同时或之前(TDD)编写。
测试边界条件: 不要只测试“正常路径(Happy Path)”,要多测试空值、负数、极大值、异常分支等边缘情况。
清晰的命名: 测试方法的名字应该明确说明测试的场景和预期结果。例如:
methodName_StateUnderTest_ExpectedBehavior(如login_InvalidPassword_ThrowsException)。
JUnit 5
提到 Java 的单元测试,JUnit 绝对是绕不开的“行业标准”。它不仅是 Java 开发者日常高频使用的工具,更是测试驱动开发(TDD)和持续集成(CI/CD)的基石。
目前 Java 生态中的绝对主流是 JUnit 5。相比于老版本的 JUnit 4,它不仅是一个单纯的测试框架,更被设计成了一个完整的测试平台。
核心模块
JUnit 5 的“三足鼎立”架构:
与 JUnit 4 将所有东西打包在一个强耦合的库中不同,JUnit 5 进行了模块化拆分,主要由三个子项目组成:
- JUnit Platform(测试平台): 这是 JUnit 5 的基础。它负责在 JVM 上启动测试框架,并提供了一个叫
TestEngine的 API,任何实现了这个 API 的测试框架(哪怕不是 JUnit)都能在这个平台上运行。IDE(如 IntelliJ IDEA)和构建工具(如 Maven/Gradle)就是通过它来发现和执行测试的。 - JUnit Jupiter(木星): 这是 JUnit 5 的核心,包含了全新的编程模型和扩展机制。我们日常写的
@Test、@BeforeEach等注解,以及各种断言,都来自于 Jupiter。 - JUnit Vintage(复古): 提供对 JUnit 3 和 JUnit 4 的向后兼容支持。如果你接手了一个老项目,里面全是 JUnit 4 的测试代码,有了 Vintage,你依然可以在 JUnit 5 的平台上运行它们。
核心注解
如果从实际使用场景出发,我们可以把 JUnit 5 的核心注解高度浓缩为 3 大类:
核心基础与运行控制
第一类:核心基础与运行控制 (Core & Execution Control):
这类注解用于定义“什么是测试”、“测试叫什么”以及“要不要运行测试”。这是编写任何单元测试的基础。
@Test:标记核心的测试方法。@DisplayName:给测试类或方法起个“说人话”的名字,方便看测试报告。@Disabled:临时禁用某个测试(比如功能正在重构时)。
代码示例:
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DisplayName("基础计算功能测试")
class CalculatorTest {
@Test
@DisplayName("1 + 1 应该等于 2")
void testAddition() {
assertEquals(2, 1 + 1);
}
@Test
@Disabled("减法接口暂未实现完毕,暂时跳过")
void testSubtraction() {
// ...
}
}生命周期管理
第二类:生命周期管理 (Lifecycle Management):
这类注解用于控制测试前后的“准备工作”和“清理工作”。它们是保证测试用例相互隔离、互不影响的关键。
@BeforeEach/@AfterEach:在每一个测试方法运行的“前/后”执行。最常用,用来重置数据、清理脏数据。@BeforeAll/@AfterAll:在整个类的所有测试运行“前/后”只执行一次(必须是静态方法)。常用于启动/关闭数据库连接等高消耗操作。
代码示例:
import org.junit.jupiter.api.*;
import java.util.ArrayList;
import java.util.List;
class ListLifecycleTest {
private List<String> list;
@BeforeAll
static void initGlobalResource() {
System.out.println("1. 全局资源初始化 (仅一次)");
}
@BeforeEach
void setUp() {
System.out.println("2. 每个测试前:初始化干净的 List");
list = new ArrayList<>();
}
@Test
void testA() { /* 测试逻辑 */ }
@Test
void testB() { /* 测试逻辑 */ }
@AfterEach
void tearDown() {
System.out.println("3. 每个测试后:清理数据");
list.clear();
}
}进阶特性与结构化
第三类:进阶特性与结构化 (Advanced Structure):
当你觉得写了很多重复的 @Test 代码,或者测试场景变得复杂时,这类注解能帮你大幅简化代码并理清结构。
@ParameterizedTest等:参数化测试。用一组不同的数据,反复运行同一个测试逻辑,拒绝复制粘贴。@Nested:嵌套测试。把针对同一个类的不同状态的测试进行分组,让测试代码像大纲目录一样清晰。
代码示例:
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
@DisplayName("进阶功能演示")
class AdvancedTest {
// 1. 参数化测试:这一个方法会带着不同的参数执行 3 次
@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 8})
@DisplayName("测试是否为偶数")
void testIsEven(int number) {
assertTrue(number % 2 == 0);
}
// 2. 嵌套测试:按场景分组
@Nested
@DisplayName("当用户已登录时")
class WhenUserLoggedIn {
@Test
void canAccessDashboard() { /* ... */ }
}
}断言机制
在单元测试中,断言(Assertions) 就是测试用例的“裁判员”。如果说 @Test 和 @BeforeEach 负责把舞台搭好、把代码跑起来,那么断言就负责判定这段代码的执行结果是否符合你的预期。如果符合,测试通过(绿灯);如果不符合,测试失败(红灯)。
JUnit 5 所有的标准断言方法都放在 org.junit.jupiter.api.Assertions 这个工具类中。为了方便理解和记忆,我将 JUnit 5 的断言机制高度概括为 3 大类:
基础状态与值验证
第一类:基础状态与值验证 (Basic Value & State Validation):
这是日常写测试用得最多的一类。它们主要用来比对方法的返回值、对象的属性,或者判断某个条件是否成立。
核心方法:
assertEquals(expected, actual)/assertNotEquals():判断预期值与实际值是否相等/不相等。assertTrue(condition)/assertFalse(condition):判断条件是否为真/为假。assertNull(object)/assertNotNull(object):判断对象是否为空/不为空。assertSame(expected, actual)/assertNotSame():判断两个对象引用是否指向内存中的同一个实例(即==判断)。
💡 避坑指南: 与老版本 JUnit 4 不同,JUnit 5 断言的失败提示信息(Message)放在了参数列表的最后面,并且支持传入 Lambda 表达式来实现“延迟计算”,从而提升性能。
代码示例:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MathUtilsTest {
@Test
void testBasicAssertions() {
int result = 10 + 5;
// 1. 验证值相等 (预期值, 实际值, 失败时的提示信息)
assertEquals(15, result, "10 + 5 应该等于 15");
// 2. 验证布尔条件
assertTrue(result > 0, () -> "计算结果应该是正数"); // Lambda 表达式
// 3. 验证对象不为 Null
String name = "Gemini";
assertNotNull(name, "名字不应该为空");
}
}异常与超时拦截
第二类:异常与超时拦截 (Exception & Timeout Interception):
优秀的代码不仅要在正常情况下输出对的值,还要在异常情况下(比如除数为 0、网络超时)做出正确的反应。这类断言专门用来测试代码的边界行为。
核心方法:
assertThrows(ExpectedType.class, Executable):断言执行某段代码一定会抛出指定的异常。assertDoesNotThrow(Executable):断言执行某段代码绝对不会抛出任何异常。assertTimeout(Duration, Executable):断言某段代码的执行时间不会超过设定的阈值。
代码示例:
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class ExceptionAndTimeoutTest {
@Test
void testException() {
// 1. 验证闭包中的代码是否抛出了 IllegalArgumentException
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
Integer.parseInt("这不是一个数字"); // 这里会抛出 NumberFormatException
});
// 还可以进一步验证异常的提示信息是否正确
assertTrue(exception.getMessage().contains("For input string"));
}
@Test
void testTimeout() {
// 2. 验证这段代码必须在 1 秒内执行完毕,否则测试失败
assertTimeout(Duration.ofSeconds(1), () -> {
// 模拟一个耗时操作
Thread.sleep(500);
});
}
}组合与批量断言
第三类:组合与批量断言 (Grouped Assertions):
常规断言有一个缺点:快速失败(Fail-fast)。如果一个测试方法里写了 5 个 assertEquals,只要第 1 个失败了,测试就会立即中断,后面 4 个到底是对是错你根本不知道,必须修好第 1 个重新跑才行。
为了解决这个问题,JUnit 5 引入了组合断言,它会把所有的断言全部执行一遍,最后把失败的情况汇总一次性全报出来。
核心方法:
assertAll(Heading, Executable...):批量执行多个断言。
代码示例:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UserTest {
@Test
void testUserProperties() {
User user = new User("John", "Doe", 30);
// 使用 assertAll 组合校验一个复杂对象的多个属性
assertAll("校验用户完整信息",
() -> assertEquals("John", user.getFirstName(), "名不对"),
() -> assertEquals("Doe", user.getLastName(), "姓不对"),
() -> assertTrue(user.getAge() >= 18, "年龄必须成年")
);
// 即使 firstName 校验失败了,lastName 和 age 的校验依然会继续执行!
}
}这 3 类断言构成了 JUnit 5 验证结果的核心武器库。
不过,在现代 Java 业界,很多高级开发者在写断言时,会放弃 JUnit 自带的断言,转而使用一个名叫 AssertJ 的第三方库,因为它提供了类似 assertThat(result).isGreaterThan(10).isNotNull() 这种更接近英语自然语言的“链式编程”写法。
进阶:参数化测试
在日常开发中,我们经常会遇到这样一种场景:测试逻辑完全一样,只是输入的测试数据和预期结果不同。
如果用普通的 @Test,你要么写无数个重复的测试方法,要么在一个方法里写一个巨大的 for 循环(这会导致一旦某个数据失败,整个测试就中断了,无法知道后续数据的结果)。
为了完美解决这个问题,JUnit 5 提供了强大的 参数化测试 (Parameterized Tests)。它允许你只写一次测试逻辑,然后向其“注入”多组不同的测试数据。
⚠️ 注意: 使用参数化测试可能需要单独引入
junit-jupiter-params依赖(如果你使用的是 Spring Boot 的spring-boot-starter-test,则已经自动包含了)。
参数化测试的核心注解是 @ParameterizedTest(用来替换 @Test)。但光有它还不够,你还必须告诉 JUnit “数据从哪里来”。JUnit 5 提供了极其丰富的数据源(Sources),以下是日常开发中最常用的 3 种实战场景:
单一参数注入
单一参数注入::@ValueSource
当你只需要测试一个参数(如 String、int、long、double 等基本类型)时,@ValueSource 是最简单直接的选择。
场景: 测试一个字符串是否为空白。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StringUtilsTest {
// JUnit 会把 ValueSource 里的每个字符串分别当作参数,执行 3 次这个方法
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t\n"})
void testIsBlank(String input) {
// 假设 StringUtils.isBlank 是我们自己写的业务代码
assertTrue(input == null || input.trim().isEmpty());
}
}多列参数注入
多列参数注入::@CsvSource (最常用)
在真实业务中,我们通常需要验证 “输入 A + 输入 B -> 预期结果 C”。这时候 @CsvSource 就派上大用场了。它允许你像写 CSV 表格一样,用逗号分隔多列数据。
场景: 测试计算器的加法功能。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
// 我们还可以通过 name 属性自定义测试报告中的显示名称
// {index} 代表当前是第几次执行,{0} 代表第一个参数,{1} 代表第二个参数,以此类推
@ParameterizedTest(name = "第 {index} 组测试:计算 {0} + {1},期望结果为 {2}")
@CsvSource({
"1, 1, 2", // 正常正数
"-1, 5, 4", // 包含负数
"0, 0, 0", // 零
"100, 200, 300" // 较大数字
})
void testAdd(int a, int b, int expectedResult) {
Calculator calc = new Calculator();
int actualResult = calc.add(a, b);
assertEquals(expectedResult, actualResult);
}
}💡 提示:如果你的数据量非常大,还可以使用 @CsvFileSource(resources = "/test-data.csv") 直接从外部文件读取测试数据。
复杂对象与动态数据注入
复杂对象与动态数据注入::@MethodSource
@ValueSource 和 @CsvSource 虽好,但只能处理简单的字符串或基本类型。如果你的测试方法需要传入一个复杂的 Java 对象(比如 User、Order),或者测试数据需要通过代码动态生成,@MethodSource 是终极武器。
它允许你指定一个静态方法的名字,这个静态方法负责生成并返回测试数据(通常是 Stream<Arguments> 类型)。
场景: 测试用户是否符合特定的折扣条件(需要传入复杂的 User 对象)。
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.params.provider.Arguments.arguments;
class DiscountServiceTest {
// 1. 定义数据提供方法(必须是 static 的)
// 返回包含 (User对象, 预期的折扣额度) 的参数流
static Stream<Arguments> provideUsersForDiscount() {
return Stream.of(
arguments(new User("张三", "VIP", 5000), 0.8), // VIP用户,且积分>1000,打8折
arguments(new User("李四", "NORMAL", 100), 1.0), // 普通用户,不打折
arguments(new User("王五", "VIP", 50), 0.9) // VIP用户,但积分低,打9折
);
}
// 2. 将 @MethodSource 指向刚才写的静态方法名
@ParameterizedTest
@MethodSource("provideUsersForDiscount")
void testCalculateDiscount(User user, double expectedDiscount) {
DiscountService service = new DiscountService();
double actualDiscount = service.calculateDiscount(user);
assertEquals(expectedDiscount, actualDiscount);
}
}进阶:嵌套测试
嵌套测试(Nested Tests) 能让你的测试代码结构和可读性产生质的飞跃的特性。
在传统的 JUnit 4 时代,我们写测试通常是“扁平化”的。如果一个类(比如 ShoppingCart 购物车)逻辑很复杂,有“空车”、“有商品”、“已结算”等多种状态,所有的测试方法全挤在一个大类里,找起来非常痛苦,而且很难共享特定状态下的初始化数据。
JUnit 5 引入了 @Nested 注解,完美解决了这个问题。它允许你在测试类中创建非静态的内部类,从而将相关的测试按场景(Context)或状态分组。
嵌套测试的核心优势
嵌套测试的核心优势:
- 结构清晰,犹如大纲: 将测试代码组织成树状结构,让你一眼就能看出这个类在不同状态下的预期行为。
- 精准的作用域控制: 每个
@Nested内部类都可以拥有自己专属的@BeforeEach和@AfterEach。这意味着你可以为不同的测试场景准备互不干扰的上下文数据。 - 支持 BDD(行为驱动开发)风格: 非常适合使用
Given-When-Then(假设-当-那么)的语义来描述业务需求。生成的测试报告就像是一份漂亮的说明文档。
代码实战
实战代码演示:
我们以一个经典的“栈(Stack)”或者“购物车(ShoppingCart)”为例。为了贴近业务,我们用购物车来演示它在“空状态”和“有商品状态”下的不同行为。
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("🛒 购物车核心逻辑测试")
class ShoppingCartTest {
private ShoppingCart cart;
// 外层的 @BeforeEach:所有内层测试运行前都会先执行这里
@BeforeEach
void initCart() {
cart = new ShoppingCart();
}
@Nested
@DisplayName("👉 场景一:当购物车为空时 (When Cart is Empty)")
class WhenEmpty {
@Test
@DisplayName("计算总价应该为 0")
void totalPriceShouldBeZero() {
assertEquals(0.0, cart.getTotalPrice());
}
@Test
@DisplayName("移除商品应该抛出异常")
void removeShouldThrowException() {
assertThrows(IllegalStateException.class, () -> cart.remove("Apple"));
}
}
@Nested
@DisplayName("👉 场景二:当购物车包含商品时 (When Cart has Items)")
class WhenHasItems {
// 内层的 @BeforeEach:仅在当前内部类的测试运行前执行
// 注意:执行顺序是 外层 @BeforeEach -> 内层 @BeforeEach -> 内层 @Test
@BeforeEach
void addSomeItems() {
cart.add("Apple", 5.0);
cart.add("Banana", 3.0);
}
@Test
@DisplayName("应该能正确计算总价")
void shouldCalculateTotalPrice() {
assertEquals(8.0, cart.getTotalPrice());
}
@Test
@DisplayName("应该能成功移除存在的商品")
void shouldRemoveExistingItem() {
cart.remove("Apple");
assertEquals(3.0, cart.getTotalPrice()); // 验证移除后的价格
}
}
}生命周期执行顺序
生命周期执行顺序(避坑指南):
在使用嵌套测试时,最容易让人迷惑的就是生命周期注解(如 @BeforeEach)的执行顺序。
记住一个核心原则:由外向内初始化,由内向外清理。
以我们上面的代码为例,当执行 WhenHasItems 内部类中的 shouldCalculateTotalPrice 测试时,真实的执行顺序是:
ShoppingCartTest.initCart()(外层初始化)WhenHasItems.addSomeItems()(内层初始化)shouldCalculateTotalPrice()(实际测试方法)(如果有内层 @AfterEach 则执行)
(如果有外层 @AfterEach 则执行)
注意事项:
默认情况下,@Nested 标注的类不能是静态(static)类。正因为它是非静态内部类,它才能无缝访问外部类的成员变量(比如上面的 cart 实例)。这也意味着,在普通的 @Nested 内部类中,你不能使用 @BeforeAll 和 @AfterAll(因为这两个注解要求方法必须是 static 的,而 Java 早期版本不允许非静态内部类包含静态方法)。
测试报告的视觉效果
测试报告的视觉效果:
当你配合 IDE(如 IntelliJ IDEA)或者 Maven/Gradle 插件运行这段代码时,你会得到一个层级分明的测试报告视图,非常惊艳:
✅ 🛒 购物车核心逻辑测试
✅ 👉 场景一:当购物车为空时 (When Cart is Empty)
✅ 计算总价应该为 0
✅ 移除商品应该抛出异常
✅ 👉 场景二:当购物车包含商品时 (When Cart has Items)
✅ 应该能正确计算总价
✅ 应该能成功移除存在的商品你看,这已经不仅仅是测试代码了,这完全就是一份活的业务需求文档!即使是不懂代码的产品经理,也能看懂这段测试在验证什么。
通过将 @Nested、@DisplayName 以及前文提到的参数化测试和 Mockito 结合起来,你就可以写出企业级、易维护且高逼格的 Java 单元测试了。
Mockito 框架
在真实的 Java 业务开发中,我们的代码往往是非常复杂的“网状结构”。比如一个 OrderService(订单服务),它在创建订单时,不仅要查数据库(依赖 Repository),还可能要调用支付接口(网络请求)、发送 MQ 消息(依赖消息队列)。
如果我们在写 OrderService 的单元测试时,真的去连数据库、调网络接口,那就违背了单元测试“快速”和“隔离”的原则(那叫集成测试)。
为了仅仅测试 OrderService 本身的业务逻辑是否正确,我们需要把这些外部依赖全部“伪造”出来。这就是 Mockito 框架诞生的意义。
Mockito 核心工作流:Mockito 是目前 Java 生态中最流行、最强大的 Mock(模拟)框架。它的核心工作流可以概括为 3 个关键步骤:创建假对象 ➔ 定义假对象的行为(打桩) ➔ 验证行为。
步骤一:创建假对象
第一步:环境配置与核心注解 (Setup & Annotations):
在 JUnit 5 中使用 Mockito,通常需要借助注解来自动帮我们创建和注入这些“假对象”。
@ExtendWith(MockitoExtension.class):写在测试类的类名上方,告诉 JUnit 5:“请把这个测试类的控制权交给 Mockito 的扩展,让它来帮我处理 Mock 注解”。@Mock:用来创建一个彻底的“假对象”(替身)。这个对象的所有方法默认都不会执行真实逻辑,只会返回 null、0 或 false。@InjectMocks:标记你要真正测试的那个类。Mockito 会自动把带有@Mock注解的假对象,注入到这个目标对象里面去(通过构造函数或反射)。
场景设定:
假设我们要测试 UserService,它依赖了一个用来查数据库的 UserRepository。
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;
// 1. 启用 Mockito 扩展
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
// 2. 创建一个假的 Repository(不要去连真数据库!)
@Mock
private UserRepository userRepository;
// 3. 这是我们要真正测试的类。Mockito 会自动把上面的假 userRepository 塞进去
@InjectMocks
private UserService userService;
// ... 下面开始写测试方法
}步骤二:打桩与模拟行为
第二步:打桩 / 模拟行为 (Stubbing):
既然 UserRepository 是个假对象,它去数据库里根本查不到数据。我们需要人为地告诉它:“当有人调用你的某个方法,并且传入特定参数时,你必须返回我指定的结果。” 这个过程在行业内叫 Stubbing(打桩)。
核心方法:
when(mock.method()).thenReturn(value):当调用某方法时,返回指定值。when(mock.method()).thenThrow(Exception):当调用某方法时,抛出指定异常。any()/anyInt()/anyString():参数匹配器。表示“无论传入什么参数都可以”。
代码示例:
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@Test
void testGetUserStatus() {
// 【打桩 1】:明确指定参数
// 告诉假对象:当别人调用 findById 并且参数是 1 时,返回一个名字叫 "Tom" 的 User 对象
when(userRepository.findById(1)).thenReturn(new User(1, "Tom", "ACTIVE"));
// 【打桩 2】:使用参数匹配器
// 告诉假对象:当别人调用 findById 并且参数是任何数字时,都返回 null (模拟找不到用户)
// when(userRepository.findById(anyInt())).thenReturn(null);
// 【打桩 3】:模拟异常抛出
// 告诉假对象:当调用 deleteById 且参数是 99 时,抛出数据库异常
// when(userRepository.deleteById(99)).thenThrow(new RuntimeException("DB Error"));
// 执行真实业务逻辑:userService 内部会调用 userRepository.findById(1)
String status = userService.getUserStatus(1);
// 断言结果是否符合预期
assertEquals("ACTIVE", status);
}步骤三:行为验证
第三步:行为验证 (Verification):
有时候,被测试的方法没有返回值(比如一个 void 方法,只负责把数据更新到数据库)。这个时候怎么判断业务逻辑是对的呢?
我们就需要验证“假对象的某个方法,是否被按预期调用了?”
核心方法:
verify(mock).method():验证方法是否被调用了(默认 1 次)。verify(mock, times(n)).method():验证方法被调用了 n 次。verify(mock, never()).method():验证方法绝对没有被调用过。
代码示例:
假设 UserService 有一个 updateUser 方法,它在更新成功后,应该调用 EmailService 发一封通知邮件。
@Mock
private EmailService emailService; // 假设又多了一个外部依赖
@Test
void testUpdateUserSendsEmail() {
// 1. 准备数据
User user = new User(1, "Tom");
when(userRepository.update(user)).thenReturn(true); // 模拟更新成功
// 2. 执行真实业务逻辑
userService.updateUser(user);
// 3. 验证行为 (Verification)
// 验证 emailService 的 sendNotifyEmail 方法确实被调用了 1 次,并且传入的参数是 "Tom"
verify(emailService, times(1)).sendNotifyEmail("Tom");
// 验证删除方法绝对没有被误调用
verify(userRepository, never()).deleteById(anyInt());
}AAA 三步走模板
总结:经典的 “三步走” 模板 (Arrange - Act - Assert):
结合 Mockito 和 JUnit 5,一个标准且极其优雅的单元测试通常长这样:
@Test
void testProcessOrder_Success() {
// 1. Arrange (准备):造数据、打桩 (when...thenReturn)
Order mockOrder = new Order("O-123", 100.0);
when(paymentGateway.charge(100.0)).thenReturn(true); // 假装扣款成功
// 2. Act (执行):调用你要测试的真实代码
boolean result = orderService.processOrder(mockOrder);
// 3. Assert & Verify (断言与验证)
assertTrue(result, "订单处理应该成功"); // 断言返回值
verify(inventoryService).deductStock("O-123"); // 验证是否扣减了库存
}通过这套组合拳,即使你的微服务依赖了 10 个外部接口、5 个数据库表,你依然可以在几毫秒内,在自己本地电脑上精准地测试完你的核心业务逻辑,且完全不需要启动 Spring 容器或连接真实的数据库。
Mockito 还有两个稍微进阶但也非常实用的神器:
@Spy(部分模拟):只模拟对象的某个方法,其他方法依然执行真实逻辑。ArgumentCaptor(参数捕获器):可以把你传给假对象的复杂参数“抓”出来,进行细致的断言。