《Spring Boot 入门》17.3 集成测试

集成测试启动完整 Spring 容器,验证跨层协作。本节讲清 @SpringBootTest 的 webEnvironment 四个取值、4.x 下 TestRestTemplate 需显式开启、上下文缓存与 @DirtiesContext、测试数据隔离的三种策略,并用 Testcontainers 2.0 接入真实数据库,最后给出三层测试的选择速查表。

本节目标:掌握 @SpringBootTest 的 webEnvironment 取值与 4.x 的 TestRestTemplate 变更,理解上下文缓存与 @DirtiesContext,学会三种测试数据隔离策略,并能用 Testcontainers 接入真实数据库。
适用版本:Spring Boot 4.1.x(Java 21)

17.3 集成测试

单元测试证明「Service 的算法对」,切片测试证明「Controller 的映射对、Repository 的查询对」,但它们都是各测各的。真实系统里这些层是串起来工作的:请求进来经 Controller 调 Service、Service 落库、事务提交、响应序列化回去。集成测试就是验证这条完整链路。

17.3.1 @SpringBootTest 与 webEnvironment

@SpringBootTest 会创建与 SpringApplication 启动时几乎一致的完整应用上下文。它有一个关键属性 webEnvironment,决定「要不要真起服务器」:

取值行为适用场景
MOCK(默认)不启动真实服务器,提供模拟的 Servlet 环境只想装配全部 Bean,再用 MockMvc 发请求
RANDOM_PORT在随机端口启动真实内嵌服务器需要真实 HTTP,集成测试首选
DEFINED_PORT用配置里的端口(默认 8080)少见,容易与本机进程冲突
NONE不创建 Web 环境非 Web 应用(批处理、命令行)

RANDOM_PORT 之所以是首选,是因为它避开了端口冲突:每次测试用一个空闲端口,本机同时跑多个测试也不会撞车。配它写出来的集成测试:

package com.example.library;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;

import com.example.library.domain.Book;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class BookApiIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void createThenGet_returnsCreatedBook() {
        Book created = restTemplate.postForObject(
                "/api/books",
                new Book(null, "Effective Java", "Joshua Bloch", 2018),
                Book.class);

        ResponseEntity<Book> got = restTemplate.getForEntity(
                "/api/books/" + created.getId(), Book.class);

        assertThat(got.getStatusCode().is2xxSuccessful()).isTrue();
        assertThat(got.getBody().getTitle()).isEqualTo("Effective Java");
    }
}

类名用 ...IT 结尾是常见约定:*Test 由 surefire 在测试阶段跑,*IT 交给 failsafe 在集成测试阶段跑,两批分开,快慢分层。

17.3.2 4.x 变更:TestRestTemplate 不再自带

上例里有两个注解值得单独说:

  • @AutoConfigureTestRestTemplate:4.x 必须显式加。3.x 里 @SpringBootTest 会自动配好 TestRestTemplate,4.x 取消了这层隐含。
  • 依赖:4.x 把 TestRestTemplate 拆到了独立模块,需要额外引入它所在的测试模块依赖,spring-boot-starter-test 不再默认带它。

如果不引入 TestRestTemplate,可以直接改用 17.2 介绍过的 RestTestClient——它在 4.x 里也能配合 @SpringBootTest 使用,并且无需额外依赖:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class BookApiRestTestClientIT {

    @Autowired
    private RestTestClient restTestClient;

    @Test
    void createThenGet() {
        restTestClient.post().uri("/api/books")
                .body(new Book(null, "Effective Java", "Joshua Bloch", 2018))
                .exchange()
                .expectStatus().isCreated();

        restTestClient.get().uri("/api/books/1")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.title").isEqualTo("Effective Java");
    }
}

一句话选择:新项目直接用 RestTestClient;已有代码迁移时按需引入 TestRestTemplate 模块。

17.3.3 为什么 @SpringBootTest 慢:上下文缓存

@SpringBootTest 慢的根源是「装配整个上下文」:扫描 Bean、建数据源、建 EntityManagerFactory、建 Tomcat。单看一次要接近一秒(第 3 章实测 0.8–1.1 秒)。

但 Spring TestContext 框架有一个上下文缓存:它按一组「配置键」缓存已建好的 ApplicationContext,配置相同的测试类会复用同一个上下文,不再重复装配。配置键包括:

  • webEnvironment 取值
  • 激活的 profile
  • @TestPropertySource / @DynamicPropertySource 注入的属性
  • 存在的 @MockitoBean / @MockitoSpyBean 及其类型
  • 上下文自定义器、@ContextConfiguration

所以让测试变快的关键,是尽量让更多测试类共享同一套配置。 反例:给每个测试类都加一个不同的 @TestPropertySource,缓存命中率为零,每个类都重新装配一次。正例:统一用 RANDOM_PORT + 同一套 profile,几十个测试类只付一次启动成本。

17.3.4 @DirtiesContext:主动让缓存失效

有时候测试会改动上下文里的状态,导致后续复用它的测试看到脏数据。这时用 @DirtiesContext 标记「这个上下文被弄脏了,用完请销毁重建」:

@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class MutableConfigTest {
    // 类执行完后销毁上下文
}

@DirtiesContext 是代价很高的注解:销毁并重建上下文意味着后续测试要重新装配一次。只在确有必要时用(例如测试修改了某个单例 Bean 的内部状态),不要为了「保险」给所有测试类都加上——那等于把缓存机制废掉。

17.3.5 测试数据隔离策略

集成测试连的是真数据库(或 Testcontainers),测试之间的数据必须隔离,否则用例顺序一变就失败。三种策略:

策略做法优点缺点
@Transactional 回滚测试方法包在事务里,结束回滚零清理代码对 RANDOM_PORT 的真实 HTTP 请求无效(请求走独立连接,不在同一事务里)
@Sql 脚本用 SQL 脚本准备/清理数据可控、可读要维护脚本
Testcontainers每个测试类/套件起一个干净数据库真方言、隔离彻底启动容器有开销

最容易踩的坑是第一行:很多人习惯给集成测试类加 @Transactional 期望自动回滚,但在 RANDOM_PORT 下请求经由真实 HTTP 打到另一个线程、另一条连接,事务根本不共享,数据照样落库。此时应改用 @Sql 或在 @BeforeEach/@AfterEach 里手动清理。

@Sql 的用法:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql(scripts = "/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class BookApiSqlIT {
    // 每个测试方法后执行 cleanup.sql 清库
}

17.3.6 @DynamicPropertySource 注入动态配置

当配置值要等到运行时才知道(典型场景:Testcontainers 启动后才知道数据库端口),用 @DynamicPropertySource 注册:

@DynamicPropertySource
static void registerProps(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

它接收一个 DynamicPropertyRegistry,用 add(属性名, 取值 Supplier) 注册。值用 Supplier 懒取,因为注册发生在容器启动前,真正的 URL 要等容器起来才有。这是 Testcontainers 手动接线的标准写法。

17.3.7 Testcontainers 2.0 接入真实数据库

内嵌 H2 的方言与生产库(如 PostgreSQL)并不完全一致,有些 SQL 在 H2 上过、在真库上挂。要验证真实方言,用 Testcontainers 起一个一次性数据库容器。4.x 依赖的是 Testcontainers 2.0。

最省事的写法是靠 @ServiceConnection——它会自动把容器的连接信息接到 Spring Boot 的数据源配置上,无需手写 @DynamicPropertySource:

package com.example.library;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import com.example.library.domain.Book;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
@Testcontainers
class BookApiContainerIT {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void createBook_persistsToRealPostgres() {
        Book created = restTemplate.postForObject(
                "/api/books",
                new Book(null, "Effective Java", "Joshua Bloch", 2018),
                Book.class);

        assertThat(created.getId()).isNotNull();
        assertThat(restTemplate.getForObject("/api/books/" + created.getId(), Book.class)
                .getTitle()).isEqualTo("Effective Java");
    }
}

要点:

  • @Testcontainers 让 JUnit 5 管理容器生命周期,@Container 标记容器字段,static 表示整个类共用一个容器。
  • @ServiceConnection 是 Spring Boot 的桥接注解:容器一启动,它就把 JDBC URL、用户名、密码注册进数据源配置。这比手写 @DynamicPropertySource 更简洁,也是当前推荐方式。
  • Testcontainers 依赖本机装了 Docker;CI 上需要能跑容器。

如果不用 @ServiceConnection(例如需要额外自定义属性),退回 17.3.6 的 @DynamicPropertySource 手动接线即可。

17.3.8 @TestConfiguration 与 @TestBean 覆盖 Bean

集成测试有时需要把一个真实 Bean 换成测试专用版本(如固定时钟)。两种方式。

@TestConfiguration:定义一个只在测试里生效的配置类,用 @Bean 提供替代实现。

@SpringBootTest
class ClockTest {

    @TestConfiguration
    static class FixedClockConfig {
        @Bean
        Clock clock() {
            return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
        }
    }
}

注意它是静态内嵌类,且只提供增量 Bean;命名 FixedClockConfig 是为了避免与主配置重名。

@TestBean:Spring 4.x 更推荐的单 Bean 覆盖方式。在测试字段上标 @TestBean,并提供一个同名的静态工厂方法返回替代实现:

@SpringBootTest
class BookServiceBeanTest {

    @TestBean
    Clock clock;

    static Clock clock() {
        return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
    }
}

@TestBean 只覆盖上下文里已存在的那个 Bean,作用范围精确、不污染其他测试,且它能用在测试类里——正好补上了 17.2 提到的「@MockitoBean 不能放进 @Configuration」的缺口。选择标准:只是替换单个 Bean 就用 @TestBean;需要一组相关配置才用 @TestConfiguration。

17.3.9 三层测试选择速查表

把本章三节收进一张表:

你要验证的东西用哪一层关键注解是否起容器
单个类的分支逻辑、算法单元测试JUnit 5 + @ExtendWith(MockitoExtension.class)否
Controller 的映射、参数绑定、JSON切片测试@WebMvcTest + @AutoConfigureMockMvc只 MVC
Repository 的派生查询、实体映射切片测试@DataJpaTest只 JPA
序列化/反序列化本身切片测试@JsonTest只 Jackson
跨层协作、事务、真实数据库方言集成测试@SpringBootTest(RANDOM_PORT) + Testcontainers是

经验配比是金字塔形:单元测试占大头,切片居中,集成测试只保留最关键的几条端到端路径。集成测试不是越多越好——它慢、脆、定位难,应当把覆盖面让给下面两层。

小结

  • @SpringBootTest 装配完整上下文;webEnvironment 四取值中 RANDOM_PORT 是集成测试首选(避开端口冲突),NONE 用于非 Web 应用。
  • 4.x 下 TestRestTemplate 需显式加 @AutoConfigureTestRestTemplate 并引入相应依赖,或改用无额外依赖的 RestTestClient。
  • 集成测试慢在上下文装配;Spring TestContext 会按配置键缓存上下文,配置一致的测试类复用同一上下文,因此应尽量减少配置差异。
  • @DirtiesContext 主动让缓存失效,代价高,只在测试会污染上下文状态时用。
  • 数据隔离三策略:@Transactional 回滚(对 RANDOM_PORT 的真实 HTTP 无效)、@Sql 脚本、Testcontainers。
  • @DynamicPropertySource 用 Supplier 懒取运行时才确定的配置,是 Testcontainers 手动接线的标准写法。
  • Testcontainers 2.0 + @ServiceConnection 自动把容器连接信息接入数据源,是验证真实数据库方言的推荐方式。
  • 覆盖 Bean 优先用 @TestBean(可放在测试类里),需要一组配置时用静态内嵌的 @TestConfiguration。

到这里,图书服务的三层测试就完整了:单元测试守住 Service 逻辑,切片测试守住 Controller 与 Repository,集成测试守住整条链路。测试写完之后,下一章我们回到应用本身,走一遍需求梳理、设计与打包发布的完整流程。

阅读导航:上一节:17.2 切片测试 · 下一节:18.1 需求与设计 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计