Skip to content

S05-12 高级特性-单元测试

[TOC]

单元测试

在软件开发中,单元测试(Unit Testing) 是保障代码质量、重构安全以及减少 Bug 的最基础也是最重要的一环。在 Java 生态中,单元测试有着非常成熟的体系和工具链。

什么是单元测试

什么是单元测试:

单元测试是指对软件中的最小可测试单元进行检查和验证。在 Java 中,这个“最小单元”通常指的是方法(Method)类(Class)

其核心思想是:隔离。将代码的一部分从其他部分中隔离出来,证明这部分逻辑是正确的,不依赖于真实的数据库、网络或其他外部系统。

单元测试的好处

  • 尽早发现 Bug: 在开发阶段就能捕捉到逻辑错误,修复成本极低。
  • 重构的底气: 有了完善的测试覆盖率,修改老代码时就不怕引入新的 Bug。
  • 代码即文档: 优秀的测试用例展示了方法应该如何被调用,以及预期的返回结果。
  • 驱动设计(TDD): 促使开发者写出高内聚、低耦合、易于测试的代码。

核心框架

Java 单元测试的核心框架:

在 Java 中,进行单元测试通常依赖以下两大“神器”的组合:

  1. JUnit: Java 最核心的测试框架,用于控制测试的生命周期和执行断言。目前主流是 JUnit 5 (Jupiter)

  2. Mockito: Java 最流行的 Mock(模拟)框架,用于模拟当前测试对象所依赖的其他组件(如数据库访问层、外部 API),从而实现真正的“隔离”。

注意事项

注意事项

  1. 被修饰的单元测试方法不能有参数
  2. 被修饰的单元测试方法不能有返回值
  3. 被修饰的单元测试方法不能是静态方法

单元测试的最佳实践

单元测试的最佳实践:

编写优秀的单元测试和编写优秀的业务代码一样重要。以下是一些业界公认的准则:

  1. 遵循 AAA 模式 (Arrange, Act, Assert):

    • Arrange(准备): 初始化测试数据和 Mock 对象。
    • Act(执行): 调用被测试的方法。
    • Assert(断言): 验证返回结果或对象状态是否符合预期。
  2. 遵循 FIRST 原则:

    • Fast (快速): 测试执行应该极快,毫秒级别。
    • Isolated (隔离): 测试之间不能相互影响,不能依赖外部环境(如真实的 DB、网络)。
    • Repeatable (可重复): 在任何环境、任何时间运行,结果都应该一致。
    • Self-validating (自验证): 测试应该自动判断成功或失败,不需要人工检查控制台输出。
    • Timely (及时): 测试应该在代码编写的同时或之前(TDD)编写。
  3. 测试边界条件: 不要只测试“正常路径(Happy Path)”,要多测试空值、负数、极大值、异常分支等边缘情况。

  4. 清晰的命名: 测试方法的名字应该明确说明测试的场景预期结果。例如: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:临时禁用某个测试(比如功能正在重构时)。

代码示例:

java
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:在整个类的所有测试运行“前/后”只执行一次(必须是静态方法)。常用于启动/关闭数据库连接等高消耗操作。

代码示例:

java
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嵌套测试。把针对同一个类的不同状态的测试进行分组,让测试代码像大纲目录一样清晰。

代码示例:

java
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 表达式来实现“延迟计算”,从而提升性能。

代码示例:

java
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):断言某段代码的执行时间不会超过设定的阈值。

代码示例:

java
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...):批量执行多个断言。

代码示例:

java
  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:

当你只需要测试一个参数(如 Stringintlongdouble 等基本类型)时,@ValueSource 是最简单直接的选择。

场景: 测试一个字符串是否为空白。

java
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 表格一样,用逗号分隔多列数据。

场景: 测试计算器的加法功能。

java
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 对象(比如 UserOrder),或者测试数据需要通过代码动态生成,@MethodSource 是终极武器

它允许你指定一个静态方法的名字,这个静态方法负责生成并返回测试数据(通常是 Stream<Arguments> 类型)。

场景: 测试用户是否符合特定的折扣条件(需要传入复杂的 User 对象)。

java
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)”为例。为了贴近业务,我们用购物车来演示它在“空状态”和“有商品状态”下的不同行为。

java
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 测试时,真实的执行顺序是:

  1. ShoppingCartTest.initCart() (外层初始化)

  2. WhenHasItems.addSomeItems() (内层初始化)

  3. shouldCalculateTotalPrice() (实际测试方法)

  4. (如果有内层 @AfterEach 则执行)

  5. (如果有外层 @AfterEach 则执行)

注意事项

默认情况下,@Nested 标注的类不能是静态(static)类。正因为它是非静态内部类,它才能无缝访问外部类的成员变量(比如上面的 cart 实例)。这也意味着,在普通的 @Nested 内部类中,你不能使用 @BeforeAll@AfterAll(因为这两个注解要求方法必须是 static 的,而 Java 早期版本不允许非静态内部类包含静态方法)。

测试报告的视觉效果

测试报告的视觉效果:

当你配合 IDE(如 IntelliJ IDEA)或者 Maven/Gradle 插件运行这段代码时,你会得到一个层级分明的测试报告视图,非常惊艳:

text
✅ 🛒 购物车核心逻辑测试
   ✅ 👉 场景一:当购物车为空时 (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

java
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():参数匹配器。表示“无论传入什么参数都可以”。

代码示例:

java
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 发一封通知邮件。

java
@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,一个标准且极其优雅的单元测试通常长这样:

java
@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 还有两个稍微进阶但也非常实用的神器:

  1. @Spy (部分模拟):只模拟对象的某个方法,其他方法依然执行真实逻辑。

  2. ArgumentCaptor (参数捕获器):可以把你传给假对象的复杂参数“抓”出来,进行细致的断言。