---
title: Java 测试体系：JUnit5 与 Spring Test 全解析
date: 2026-03-11
---

> 本文由 Claude Code 和我共同完成 😂😂😂

Java 的测试体系是每个 Java 开发者都绕不开的话题。本文覆盖从 Maven 构建集成、JUnit5 核心用法、断言体系、测试覆盖率到 Spring Test 的完整知识链路。

## Maven 构建生命周期与测试阶段

Maven 的默认生命周期（default lifecycle）共有 23 个阶段，按顺序执行。与测试相关的几个关键阶段如下：

```
validate
    ↓
compile          ← 编译 src/main/java
    ↓
test-compile     ← 编译 src/test/java
    ↓
test             ← Surefire 在此阶段运行单元测试（UT）
    ↓
package          ← 打包成 jar/war
    ↓
pre-integration-test   ← 启动外部资源（如嵌入式数据库、容器）
    ↓
integration-test       ← Failsafe 在此阶段运行集成测试（IT）
    ↓
post-integration-test  ← 关闭外部资源
    ↓
verify           ← Failsafe 在此阶段汇报集成测试结果，失败则中断构建
    ↓
install          ← 安装到本地 Maven 仓库
    ↓
deploy           ← 发布到远程仓库
```

这个顺序意味着：执行 `mvn install` 时，UT 和 IT 都会运行；执行 `mvn test` 时只运行 UT；执行 `mvn verify` 时两者都运行但不 install。

在 Maven 中，测试的执行由两个插件负责：

- **maven-surefire-plugin**：负责运行单元测试，绑定在 `test` 阶段。
- **maven-failsafe-plugin**：负责运行集成测试，绑定在 `integration-test` 和 `verify` 阶段。

两者最重要的区别是：Surefire 中测试失败会立即中断构建，Failsafe 则会在 `post-integration-test` 阶段完成资源清理后，才在 `verify` 阶段报告失败。这样可以保证即使测试失败，也不会留下没有关闭的资源（例如启动的数据库、Docker 容器等）。

### Surefire 插件默认约定

Surefire 会自动扫描并运行符合以下命名规则的测试类：

- `**/Test*.java`
- `**/*Test.java`
- `**/*Tests.java`
- `**/*TestCase.java`

Failsafe 的默认扫描规则类似，但前后缀带 `IT`：

- `**/IT*.java`
- `**/*IT.java`
- `**/*ITCase.java`

如果你使用 JUnit5，需要确保 Surefire 版本在 2.22.0 以上：

```xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
</plugin>
```

### 实际项目中几乎不需要关心 IT 插件

尽管 Maven 为 IT 设计了单独的阶段和 Failsafe 插件，但在绝大多数 Java 项目里，**我们不区分 UT 和 IT 的构建流程**。即便测试用了 `@SpringBootTest`，本质上是集成测试，但我们依然把它命名为 `*Test.java`，交给 Surefire 在 `test` 阶段统一管理。所有测试走同一套流程，简单直接。

只有在少数场景下才需要引入 Failsafe：测试强依赖外部资源（真实数据库、第三方服务、Docker 容器），需要在测试前启动、测试后关闭，这时才有必要用 `pre-integration-test` / `post-integration-test` 阶段做生命周期管理。

因此，**默认情况下只需要配置 Surefire 即可，不需要引入 Failsafe**。

### maven-surefire-report-plugin：测试结果报告

`maven-surefire-plugin` 运行完测试后，会在 `target/surefire-reports/` 目录下生成 XML 和 TXT 格式的原始结果文件，每个测试类对应一个文件，记录了通过、失败、跳过的测试及耗时。

`maven-surefire-report-plugin` 的作用是读取这些 XML，生成一份可读的 HTML 报告，供人浏览。两者是配套关系，Surefire 负责产出原始数据，Surefire Report 负责把数据可视化。

```xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-report-plugin</artifactId>
    <version>3.2.5</version>
    <executions>
        <execution>
            <phase>test</phase>
            <goals>
                <!-- 生成独立的 HTML 报告，不依赖 mvn site -->
                <goal>report-only</goal>
            </goals>
        </execution>
    </executions>
</plugin>
```

运行 `mvn test` 后，HTML 报告生成在 `target/site/surefire-report.html`。

需要注意，**这个报告和 JaCoCo 完全没有关系**。Surefire Report 告诉你"哪些测试通过了"，JaCoCo 告诉你"哪些代码被测试覆盖到了"，两者数据来源不同，报告内容也不同，只是恰好都输出到 `target/site/` 目录下。

## 如何跳过测试

### 几种跳过方式的区别

- `-DskipTests`：只跳过测试执行，测试类仍然会被编译（`test-compile` 阶段照常运行），只影响 Surefire。
- `-Dmaven.test.skip=true`：同时跳过编译和执行，Surefire 和 Failsafe 都受影响，`target/test-classes` 不会产生。
- Surefire 配置 `<skipTests>true</skipTests>`：效果同 `-DskipTests`，**不会跳过测试类的编译**，只是不运行测试方法。

一个容易误解的地方：在 pom.xml 里配置了 `<skipTests>true</skipTests>`，不等于测试代码不编译，`test-compile` 阶段照样执行。真正能同时跳过编译和执行的，只有 `-Dmaven.test.skip=true`。

```bash
# 跳过测试执行，但仍然编译测试类（常用，打包时跳过）
mvn install -DskipTests

# 跳过测试编译和执行（彻底跳过，编译产物中没有 test-classes）
mvn install -Dmaven.test.skip=true
```

在 pom.xml 中配置跳过（只跳过执行，不跳过编译）：

```xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <skipTests>true</skipTests>
    </configuration>
</plugin>
```

### 只运行指定测试

```bash
# 运行单个测试类
mvn test -Dtest=UserServiceTest

# 运行指定测试方法
mvn test -Dtest=UserServiceTest#testCreateUser

# 运行多个测试类
mvn test -Dtest=UserServiceTest,OrderServiceTest
```

### 排除指定测试

```xml
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <excludes>
            <exclude>**/SlowTest.java</exclude>
        </excludes>
    </configuration>
</plugin>
```

## JUnit5 基础

### 依赖引入

JUnit5 由三个模块组成：

- **JUnit Platform**：测试框架运行的基础平台，提供 Launcher API。
- **JUnit Jupiter**：JUnit5 的编程模型和扩展模型，包含我们日常使用的注解和 API。
- **JUnit Vintage**：提供运行 JUnit3/4 测试的引擎，用于兼容旧代码。

实际开发中通常引入聚合依赖：

```xml
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
```

如果使用 Spring Boot，`spring-boot-starter-test` 已经内置了 JUnit5，无需额外添加。

### 测试类结构

```java
import org.junit.jupiter.api.*;

class CalculatorTest {

    // 在当前测试类所有测试方法执行前执行一次
    // 默认生命周期（PER_METHOD）下必须是 static 方法
    @BeforeAll
    static void initAll() {
        System.out.println("初始化测试环境");
    }

    // 在每个测试方法执行前执行
    @BeforeEach
    void init() {
        System.out.println("每个测试前的准备");
    }

    @Test
    @DisplayName("两数相加")
    void testAdd() {
        int result = 1 + 2;
        Assertions.assertEquals(3, result);
    }

    // 在每个测试方法执行后执行
    @AfterEach
    void tearDown() {
        System.out.println("每个测试后的清理");
    }

    // 在当前测试类所有测试方法执行后执行一次
    // 默认生命周期（PER_METHOD）下必须是 static 方法
    @AfterAll
    static void tearDownAll() {
        System.out.println("清理测试环境");
    }
}
```

JUnit5 默认为每个测试方法创建一个新的测试类实例（`PER_METHOD`），因此 `@BeforeAll` / `@AfterAll` 必须是 `static` 方法。如果在类上加 `@TestInstance(TestInstance.Lifecycle.PER_CLASS)`，整个测试类只创建一个实例，此时这两个注解就不需要 `static` 了：

```java
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SomeTest {

    // PER_CLASS 下不需要 static
    @BeforeAll
    void initAll() { ... }

    @AfterAll
    void tearDownAll() { ... }
}
```

`PER_CLASS` 在 `@Nested` 内部类中也很常用——因为内部类不能有 `static` 方法，如果内部类需要 `@BeforeAll`，就必须配合 `@TestInstance(Lifecycle.PER_CLASS)` 使用。

### 常用注解

- `@Test`：标记一个测试方法。
- `@DisplayName`：为测试类或方法设置展示名称，支持中文和空格，在报告和 IDE 里更易读。
- `@Disabled`：禁用测试类或方法，可附带原因说明。
- `@Tag`：为测试打标签，可在构建时按标签过滤运行。
- `@Nested`：标记嵌套测试类，用于组织有层次的测试结构。
- `@RepeatedTest`：将测试方法重复执行指定次数。
- `@ParameterizedTest`：参数化测试，配合数据源注解多次运行同一逻辑。
- `@Timeout`：设置测试超时时间，超时则视为失败。
- `@TempDir`：注入一个临时目录，测试结束后自动清理。

### 禁用测试

```java
@Disabled("功能尚未实现，暂时跳过")
@Test
void testNotImplemented() {
    // ...
}
```

### 嵌套测试

嵌套测试非常适合描述 Given/When/Then 这类场景：

```java
@DisplayName("订单服务测试")
class OrderServiceTest {

    @Nested
    @DisplayName("当商品库存充足时")
    class WhenStockSufficient {

        @Test
        @DisplayName("下单成功")
        void shouldCreateOrderSuccessfully() {
            // ...
        }
    }

    @Nested
    @DisplayName("当商品库存不足时")
    class WhenStockInsufficient {

        @Test
        @DisplayName("下单失败并抛出异常")
        void shouldThrowException() {
            // ...
        }
    }
}
```

### 参数化测试

参数化测试是 JUnit5 的亮点之一，可以用一组不同的输入数据重复执行同一个测试逻辑。

```java
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testIsPositive(int number) {
    assertTrue(number > 0);
}

@ParameterizedTest
@ValueSource(strings = {"hello", "world", "java"})
void testIsNotBlank(String str) {
    assertFalse(str.isBlank());
}
```

使用 `@CsvSource` 可以提供多个参数：

```java
@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "2, 3, 5",
    "10, 20, 30"
})
void testAdd(int a, int b, int expected) {
    assertEquals(expected, a + b);
}
```

使用 `@MethodSource` 可以引用一个静态方法提供参数，适合复杂对象：

```java
@ParameterizedTest
@MethodSource("provideUsers")
void testUserIsValid(User user, boolean expected) {
    assertEquals(expected, userValidator.isValid(user));
}

static Stream<Arguments> provideUsers() {
    return Stream.of(
        Arguments.of(new User("alice", "alice@example.com"), true),
        Arguments.of(new User("", "invalid"), false)
    );
}
```

## 断言

### JUnit5 内置断言

JUnit5 的断言都在 `org.junit.jupiter.api.Assertions` 类中，推荐使用静态导入：

```java
import static org.junit.jupiter.api.Assertions.*;
```

**基础断言：**

```java
// 相等断言
assertEquals(expected, actual);
assertEquals(expected, actual, "失败时的消息");

// 不相等
assertNotEquals(unexpected, actual);

// 为 null / 不为 null
assertNull(object);
assertNotNull(object);

// 布尔断言
assertTrue(condition);
assertFalse(condition);

// 数组相等（深度比较）
assertArrayEquals(expectedArray, actualArray);

// 同一个对象引用
assertSame(expected, actual);
assertNotSame(unexpected, actual);
```

**异常断言：**

```java
// 断言抛出指定异常，并可以对异常对象进一步断言
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
    userService.createUser(null);
});
assertEquals("用户名不能为空", exception.getMessage());

// 断言不抛出任何异常
assertDoesNotThrow(() -> {
    userService.createUser(validUser);
});
```

**组合断言：**

`assertAll` 会执行所有断言，并在最后统一报告所有失败，而不是遇到第一个失败就停止：

```java
assertAll("用户信息",
    () -> assertEquals("alice", user.getName()),
    () -> assertEquals("alice@example.com", user.getEmail()),
    () -> assertTrue(user.isActive())
);
```

**超时断言：**

```java
// 断言在指定时间内完成
assertTimeout(Duration.ofMillis(100), () -> {
    // 被测代码
    Thread.sleep(50);
});
```

### AssertJ：更流畅的断言库

JUnit5 内置断言功能有限，实际项目中更推荐使用 **AssertJ**，它提供链式调用、更丰富的匹配器和更清晰的错误消息。

Spring Boot 的 `spring-boot-starter-test` 已包含 AssertJ，无需额外引入。

```xml
<!-- 如果不用 Spring Boot，单独引入 -->
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>
```

```java
import static org.assertj.core.api.Assertions.*;

// 字符串断言
assertThat("Hello World")
    .isNotNull()
    .startsWith("Hello")
    .endsWith("World")
    .contains("lo W")
    .hasSize(11);

// 数字断言
assertThat(42)
    .isGreaterThan(10)
    .isLessThan(100)
    .isBetween(40, 50);

// 集合断言
List<String> names = List.of("Alice", "Bob", "Charlie");
assertThat(names)
    .hasSize(3)
    .contains("Alice", "Bob")
    .doesNotContain("Dave")
    .allMatch(name -> name.length() > 2);

// 对象断言
assertThat(user)
    .isNotNull()
    .extracting(User::getName, User::getEmail)
    .containsExactly("alice", "alice@example.com");

// 异常断言
assertThatThrownBy(() -> userService.findById(-1))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("ID 不合法");

// 或者使用这种风格
assertThatExceptionOfType(IllegalArgumentException.class)
    .isThrownBy(() -> userService.findById(-1))
    .withMessageContaining("ID 不合法");
```

## Mockito：Mock、Stub、Verify

单元测试的核心原则是隔离被测单元。被测代码往往依赖数据库、外部服务、邮件系统等，这些在测试中需要用"替代品"替换掉。Mockito 的使用围绕三个步骤展开：

```
Mock → Stub → Verify
创建替代品 → 控制它的行为 → 验证它被正确调用
```

### 第一步：Mock（创建替代品）

`mock()` 创建一个假对象，它实现了接口或继承了类，但所有方法默认什么都不做（返回 null / 0 / false）：

```java
import static org.mockito.Mockito.*;

UserRepository userRepository = mock(UserRepository.class);
EmailService emailService = mock(EmailService.class);
```

使用注解更简洁，配合 `@ExtendWith(MockitoExtension.class)` 使用：

```java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

    @InjectMocks  // 自动将上面的 mock 注入到 UserService
    private UserService userService;
}
```

### 第二步：Stub（控制返回值）

Mock 对象默认返回空值，通常需要给它"打桩"，让它在特定调用时返回你想要的数据：

```java
// 返回指定值
when(userRepository.findById(1L))
    .thenReturn(Optional.of(new User(1L, "alice")));

// 抛出异常
when(userRepository.findById(-1L))
    .thenThrow(new IllegalArgumentException("ID 不合法"));

// 连续调用返回不同值
when(userRepository.count())
    .thenReturn(0L)
    .thenReturn(1L);
```

参数匹配器可以让 stub 更灵活：

```java
// any() 匹配任意参数
when(userRepository.save(any(User.class))).thenReturn(savedUser);

// anyLong() / anyString() 等同理
when(userRepository.findByName(anyString())).thenReturn(Optional.empty());
```

### 第三步：Verify（验证调用行为）

`verify()` 用来断言 mock 方法是否被调用、调用了几次、传入了什么参数：

```java
// 默认验证调用了一次
verify(emailService).sendWelcomeEmail("alice@example.com");

// 验证调用次数
verify(userRepository, times(2)).save(any(User.class));
verify(userRepository, never()).deleteById(anyLong());
verify(userRepository, atLeastOnce()).findById(anyLong());
```

### 完整示例

三步串联在一起，是一个典型单元测试的骨架：

```java
@Test
void testCreateUser() {
    // Stub：让 repository 的 save 返回保存后的对象
    User user = new User("alice", "alice@example.com");
    when(userRepository.save(any(User.class))).thenReturn(user);

    // 执行被测方法
    userService.createUser("alice", "alice@example.com");

    // Verify：确认 repository 和 emailService 都被正确调用了
    verify(userRepository).save(any(User.class));
    verify(emailService).sendWelcomeEmail("alice@example.com");
}
```

### ArgumentCaptor：捕获参数做细节断言

有时候光验证"方法被调用了"还不够，还需要检查传入的参数内容：

```java
@Captor
ArgumentCaptor<User> userCaptor;

@Test
void testCreateUser() {
    userService.createUser("alice", "alice@example.com");

    verify(userRepository).save(userCaptor.capture());
    User savedUser = userCaptor.getValue();

    assertThat(savedUser.getName()).isEqualTo("alice");
    assertThat(savedUser.getCreatedAt()).isNotNull();
}
```

### Spy：包装真实对象

除了完全替换的 Mock，还有一种 **Spy**，它是对真实对象的包装。未被 stub 的方法仍然调用真实实现，只有你明确 stub 过的方法才会被替换。

两种创建方式：

```java
// 方式一：注解（需要 @ExtendWith(MockitoExtension.class)）
@Spy
private UserValidator userValidator = new UserValidator();

// 方式二：静态方法
UserValidator userValidator = spy(new UserValidator());
```

Spy 的 stub 必须用 `doReturn()` 而不是 `when().thenReturn()`：

```java
// ✅ 正确：doReturn 不会触发真实方法
doReturn(true).when(userValidator).isEmailValid(anyString());

// ❌ 危险：when(...) 括号内会先调用真实方法，可能抛异常或产生副作用
when(userValidator.isEmailValid(anyString())).thenReturn(true);
```

原因是 `when(spy.method())` 这行代码在执行时，`spy.method()` 已经被真实调用了一次。如果这个方法有副作用（比如发网络请求、操作数据库），就会出问题。`doReturn().when()` 则是先注册 stub 规则，完全不触发真实方法。

Spy 适合"只想替换某一个方法、其余保持真实"的场景，在测试遗留代码时尤其有用。

## JaCoCo 测试覆盖率

JaCoCo（Java Code Coverage）是 Java 最主流的代码覆盖率工具，它通过字节码插桩的方式，统计测试执行期间哪些代码被覆盖到了。

### 在 Maven 中集成 JaCoCo

JaCoCo 的工作原理是：在测试运行时通过 Java Agent 收集覆盖率数据，写入一个二进制文件（默认是 `target/jacoco.exec`），再基于这份数据文件生成报告。默认只收集 `test` 阶段（Surefire）的数据，也就是我们通常所说的"测试覆盖率"。

```xml
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <execution>
            <id>prepare-agent</id>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>
```

运行 `mvn test` 后，HTML 报告生成在 `target/site/jacoco/index.html`。

### 配置覆盖率阈值

可以配置最低覆盖率要求，低于阈值时构建失败：

```xml
<execution>
    <id>check</id>
    <goals>
        <goal>check</goal>
    </goals>
    <configuration>
        <rules>
            <rule>
                <element>BUNDLE</element>
                <limits>
                    <!-- 行覆盖率不低于 80% -->
                    <limit>
                        <counter>LINE</counter>
                        <value>COVEREDRATIO</value>
                        <minimum>0.80</minimum>
                    </limit>
                    <!-- 分支覆盖率不低于 70% -->
                    <limit>
                        <counter>BRANCH</counter>
                        <value>COVEREDRATIO</value>
                        <minimum>0.70</minimum>
                    </limit>
                </limits>
            </rule>
        </rules>
    </configuration>
</execution>
```

### 排除不需要统计覆盖率的类

```xml
<configuration>
    <excludes>
        <!-- 排除生成的代码、配置类、DO 类等 -->
        <exclude>**/generated/**</exclude>
        <exclude>**/*Config.class</exclude>
        <exclude>**/*DO.class</exclude>
        <exclude>com/example/Application.class</exclude>
    </excludes>
</configuration>
```

### JaCoCo 覆盖率指标说明

JaCoCo 提供多个维度的覆盖率统计：

- **LINE**（行覆盖率）：源代码行是否被执行，最直观。
- **BRANCH**（分支覆盖率）：`if`/`switch` 等条件的每个分支是否都被覆盖，能发现遗漏的边界条件。
- **METHOD**（方法覆盖率）：方法是否被调用过。
- **CLASS**（类覆盖率）：类是否被实例化或调用过。
- **INSTRUCTION**（指令覆盖率）：JVM 字节码指令粒度的覆盖，是最精确的底层指标。
- **COMPLEXITY**（复杂度覆盖率）：基于圈复杂度计算，反映控制流路径的覆盖情况。

实践中，通常以**行覆盖率**和**分支覆盖率**作为主要指标。

如果项目同时跑了 IT（由 Failsafe 管理），JaCoCo 默认的报告不会包含这部分覆盖数据。需要额外配置：用 `prepare-agent-integration` goal 在 IT 阶段也挂载 Agent、收集独立的 `.exec` 数据文件，再通过 `merge` goal 将 UT 和 IT 两份数据合并，最后生成统一报告。原理与 UT 完全一致，只是多了一次数据收集和一次合并步骤。

## Spring Test

在没有 Spring Test 的年代，想测试一个 Spring Bean 需要自己写代码手动创建 ApplicationContext、手动装配依赖。而且每个测试类都这样做一遍，启动成本极高。Spring Test 就是为了解决这个问题而诞生的。

### Spring Test 做了什么

**1. 统一管理 ApplicationContext 的生命周期**

Spring Test 的核心是 **TestContext Framework**，它在测试框架（JUnit5）和 Spring 容器之间充当桥梁。它负责：

- 解析测试类上的注解（`@SpringBootTest`、`@ContextConfiguration` 等）
- 根据注解创建对应的 ApplicationContext
- 在测试类中完成依赖注入（`@Autowired`）

**2. ApplicationContext 缓存复用**

Spring Test 最重要的优化是**上下文缓存**。Spring 容器启动一次至少需要几秒钟，如果每个测试类都重新创建一次 ApplicationContext，几百个测试类跑下来时间不可接受。

Spring Test 会根据测试的配置（使用的配置类、激活的 Profile、属性等）生成一个缓存 key，相同 key 的测试类共享同一个 ApplicationContext 实例，整个测试套件只启动一次容器。

```java
// 这两个测试类配置相同，会共享同一个 ApplicationContext
@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest { ... }

@SpringBootTest
@ActiveProfiles("test")
class OrderServiceTest { ... }
```

一旦修改了上下文配置（比如加了 `@MockBean`、换了不同的 Profile），就会创建新的上下文，缓存失效。所以要尽量保持测试配置统一，减少上下文数量。

**3. 测试中的事务管理**

在测试类或方法上加 `@Transactional`，Spring Test 会在测试结束后自动**回滚事务**，避免测试数据污染。这是生产代码中 `@Transactional` 默认提交的行为，在测试中被特意改成了回滚。

**4. Test Slice（测试切片）**

Spring Boot 在 Spring Test 的基础上提供了一系列 Test Slice 注解，每个 Slice 只加载 ApplicationContext 的一个子集，避免加载整个完整上下文，让测试更快更聚焦：

- `@WebMvcTest`：只加载 Controller、Filter、MVC 配置，不加载 Service/Repository，适合专门测试 Controller 层。
- `@DataJpaTest`：只加载 JPA Repository 和 EntityManager，不加载 Controller/Service，适合测试数据访问层。
- `@DataRedisTest`：只加载 Redis 相关组件。
- `@JsonTest`：只加载 JSON 序列化/反序列化相关组件，适合测试 Jackson 配置是否正确。

### 为什么需要 Spring Test

对于 Spring 应用，很多逻辑不是孤立的，而是建立在 Spring 的能力之上：

- Controller 层依赖 Spring MVC 的请求映射、参数绑定、消息转换
- Service 层的事务由 Spring AOP 代理处理
- Repository 层的 SQL 由 Spring Data JPA 生成
- 配置注入依赖 `@Value`、`@ConfigurationProperties`

这些行为在纯单元测试中根本无法测到，必须有 Spring 容器的参与才能验证。Spring Test 让你在有容器的环境中写测试，同时又提供了足够多的工具来保持测试的速度和隔离性。

### @SpringBootTest：集成测试

`@SpringBootTest` 会启动完整的 Spring ApplicationContext，适合做集成测试：

```java
@SpringBootTest
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService;

    @Test
    void testCreateAndFindUser() {
        User user = userService.create("alice", "alice@example.com");
        assertThat(user.getId()).isNotNull();

        User found = userService.findById(user.getId());
        assertThat(found.getName()).isEqualTo("alice");
    }
}
```

`@SpringBootTest` 默认使用 `MOCK` 环境（创建 MockServletContext，不占用真实端口），可以通过 `webEnvironment` 参数控制：

```java
// 默认值：创建 MockServletContext，不占用真实端口，配合 MockMvc 使用
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)

// 使用随机端口启动真实的 Web 服务器，适合端到端 HTTP 测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)

// 彻底不创建 Web 环境，适合纯 Service 层的集成测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
```

使用 `RANDOM_PORT` 时，Spring 会自动注入 `TestRestTemplate`，它已绑定了随机端口，可以直接发起真实 HTTP 请求：

```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void testGetUser() {
        ResponseEntity<User> response = restTemplate.getForEntity("/api/users/1", User.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().getName()).isEqualTo("alice");
    }
}
```

WebFlux 项目对应的是 `WebTestClient`，用法类似但支持响应式链式调用。

### @MockBean 和 @SpyBean（以及 Spring Boot 3.4+ 的新注解）

在 Spring 测试中，可以用 `@MockBean` 替换 Spring 容器中的 Bean 为 Mockito Mock 对象，用 `@SpyBean` 对真实 Bean 做 Spy 包装（未被 stub 的方法仍然调用真实实现）。

**Spring Boot < 3.4（仍在维护的主流版本）**，使用 `spring-test` 模块提供的注解：

```java
@SpringBootTest
class UserServiceTest {

    @Autowired
    private UserService userService;

    @MockBean
    private EmailService emailService; // 替换容器中真实的 EmailService

    @Test
    void testCreateUser() {
        userService.createUser("alice", "alice@example.com");
        verify(emailService).sendWelcomeEmail("alice@example.com");
    }
}
```

**Spring Boot 3.4+ 引入了新注解**：`@MockitoBean` 和 `@MockitoSpyBean`，分别替代 `@MockBean` 和 `@SpyBean`，旧注解已标注 `@Deprecated`。新注解来自 `spring-test` 模块，**包名从 `org.springframework.boot.test.mock.mockito` 变更为 `org.springframework.test.context.bean.override.mockito`**：

```java
// Spring Boot 3.4+ 推荐写法
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@SpringBootTest
class UserServiceTest {

    @MockitoBean
    private EmailService emailService;
}
```

功能和语义与旧注解完全一致，迁移成本极低，只需替换注解名和 import。**如果你的项目运行在 Spring Boot 3.4+，直接用新注解；3.4 以下继续用 `@MockBean` / `@SpyBean` 即可，两套注解不能混用。**

注意：无论新旧注解，使用它们都会导致上下文缓存失效，每次都会重新创建 ApplicationContext，对测试速度有影响，建议谨慎使用。

### MockMvc：测试 Web 层

MockMvc 可以在不启动真实 HTTP 服务器的情况下，测试 Controller 层：

```java
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void testGetUser() throws Exception {
        mockMvc.perform(get("/api/users/1")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"));
    }

    @Test
    void testCreateUser() throws Exception {
        CreateUserRequest request = new CreateUserRequest("alice", "alice@example.com");

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").isNotEmpty());
    }
}
```

如果只想测试 Controller 层而不加载完整上下文，可以使用 `@WebMvcTest`：

```java
@WebMvcTest(UserController.class) // 只加载 UserController 相关的组件
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean // Spring Boot 3.4+ 改用 @MockitoBean
    private UserService userService; // 手动提供 Controller 的依赖
}
```

### @DataJpaTest：测试数据访问层

专门用于测试 JPA Repository，只加载与 JPA 相关的组件，默认使用内嵌数据库（H2）并在每个测试后回滚事务：

```java
@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void testFindByEmail() {
        userRepository.save(new User("alice", "alice@example.com"));

        Optional<User> found = userRepository.findByEmail("alice@example.com");
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("alice");
    }
}
```

如果需要使用真实数据库而非 H2，可以添加：

```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {
    // 使用 application-test.properties 中配置的真实数据库
}
```

### 测试配置隔离

通常会为测试环境单独配置一份 `application-test.properties` 或 `application-test.yml`：

```yaml
# src/test/resources/application-test.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
```

通过 `@ActiveProfiles` 激活测试 profile：

```java
@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest {
    // ...
}
```

### @Transactional 在测试中的应用

在测试类或测试方法上加 `@Transactional`，测试结束后会自动回滚数据库操作，保证测试之间相互隔离：

```java
@SpringBootTest
@Transactional
class UserServiceTest {

    @Autowired
    private UserService userService;

    @Test
    void testCreateUser() {
        // 这里的数据库操作在测试结束后会自动回滚
        userService.create("alice", "alice@example.com");
        assertThat(userService.count()).isEqualTo(1);
    }
}
```

## 单元测试 vs 集成测试的选择

**单元测试**速度极快（毫秒级），依赖全部 Mock，完全隔离，适合测试业务逻辑、算法、工具类，代表注解是 `@ExtendWith(MockitoExtension.class)`。

**集成测试**需要启动 Spring 上下文，速度慢（秒级），使用真实依赖，更接近生产环境，适合验证 Controller、Repository、Service 之间的协作，代表注解是 `@SpringBootTest`、`@DataJpaTest`、`@WebMvcTest`。

实践建议：优先编写单元测试，对于需要验证组件间协作的场景再编写集成测试。对 Service 层要尽量做单元测试（Mock 掉 Repository），集成测试主要用于 Controller 和 Repository 层的验证。

（全文完）
