本文由 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-testverify 阶段。

两者最重要的区别是: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 以上:

<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 负责把数据可视化。

<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

# 跳过测试执行,但仍然编译测试类(常用,打包时跳过)
mvn install -DskipTests

# 跳过测试编译和执行(彻底跳过,编译产物中没有 test-classes)
mvn install -Dmaven.test.skip=true

在 pom.xml 中配置跳过(只跳过执行,不跳过编译):

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

只运行指定测试

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

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

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

排除指定测试

<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 测试的引擎,用于兼容旧代码。

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

<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,无需额外添加。

测试类结构

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

@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:注入一个临时目录,测试结束后自动清理。

禁用测试

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

嵌套测试

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

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

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

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

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

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

参数化测试

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

@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 可以提供多个参数:

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

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

@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", "[email protected]"), true),
        Arguments.of(new User("", "invalid"), false)
    );
}

断言

JUnit5 内置断言

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

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

基础断言:

// 相等断言
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);

异常断言:

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

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

组合断言:

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

assertAll("用户信息",
    () -> assertEquals("alice", user.getName()),
    () -> assertEquals("[email protected]", user.getEmail()),
    () -> assertTrue(user.isActive())
);

超时断言:

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

AssertJ:更流畅的断言库

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

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

<!-- 如果不用 Spring Boot,单独引入 -->
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>
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", "[email protected]");

// 异常断言
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):

import static org.mockito.Mockito.*;

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

使用注解更简洁,配合 @ExtendWith(MockitoExtension.class) 使用:

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private EmailService emailService;

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

第二步:Stub(控制返回值)

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

// 返回指定值
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 更灵活:

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

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

第三步:Verify(验证调用行为)

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

// 默认验证调用了一次
verify(emailService).sendWelcomeEmail("[email protected]");

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

完整示例

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

@Test
void testCreateUser() {
    // Stub:让 repository 的 save 返回保存后的对象
    User user = new User("alice", "[email protected]");
    when(userRepository.save(any(User.class))).thenReturn(user);

    // 执行被测方法
    userService.createUser("alice", "[email protected]");

    // Verify:确认 repository 和 emailService 都被正确调用了
    verify(userRepository).save(any(User.class));
    verify(emailService).sendWelcomeEmail("[email protected]");
}

ArgumentCaptor:捕获参数做细节断言

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

@Captor
ArgumentCaptor<User> userCaptor;

@Test
void testCreateUser() {
    userService.createUser("alice", "[email protected]");

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

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

Spy:包装真实对象

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

两种创建方式:

// 方式一:注解(需要 @ExtendWith(MockitoExtension.class))
@Spy
private UserValidator userValidator = new UserValidator();

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

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

// ✅ 正确: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)的数据,也就是我们通常所说的”测试覆盖率”。

<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

配置覆盖率阈值

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

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

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

<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 实例,整个测试套件只启动一次容器。

// 这两个测试类配置相同,会共享同一个 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,适合做集成测试:

@SpringBootTest
class UserServiceIntegrationTest {

    @Autowired
    private UserService userService;

    @Test
    void testCreateAndFindUser() {
        User user = userService.create("alice", "[email protected]");
        assertThat(user.getId()).isNotNull();

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

@SpringBootTest 默认使用 MOCK 环境(创建 MockServletContext,不占用真实端口),可以通过 webEnvironment 参数控制:

// 默认值:创建 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 请求:

@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 模块提供的注解:

@SpringBootTest
class UserServiceTest {

    @Autowired
    private UserService userService;

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

    @Test
    void testCreateUser() {
        userService.createUser("alice", "[email protected]");
        verify(emailService).sendWelcomeEmail("[email protected]");
    }
}

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

// 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 层:

@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("[email protected]"));
    }

    @Test
    void testCreateUser() throws Exception {
        CreateUserRequest request = new CreateUserRequest("alice", "[email protected]");

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

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

@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)并在每个测试后回滚事务:

@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void testFindByEmail() {
        userRepository.save(new User("alice", "[email protected]"));

        Optional<User> found = userRepository.findByEmail("[email protected]");
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("alice");
    }
}

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

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

测试配置隔离

通常会为测试环境单独配置一份 application-test.propertiesapplication-test.yml

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

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

@Transactional 在测试中的应用

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

@SpringBootTest
@Transactional
class UserServiceTest {

    @Autowired
    private UserService userService;

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

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

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

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

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

(全文完)