前面的测试分别验证了 Service、MVC、Repository、事务和 RestClient。这些测试定位问题快,却没有证明所有组件装配到一起后仍能完成一次真实请求。例如安全 Filter、JSON 转换、业务事务和数据库迁移可能各自正确,组合时仍可能因为 Bean、配置或路径不一致而失败。
本章创建应用级端到端集成测试:启动真实 Web 服务器,监听随机端口,从 HTTP 请求进入应用,经过 Spring Security、Controller、Service 和 Repository,最后写入 Testcontainers 提供的 PostgreSQL。身份提供方与被监控服务不属于当前应用,测试会在它们的边界提供可控替身。
1. 这次要验证多大的范围
应用级端到端测试覆盖以下路径:
1RestTestClient2-> 随机端口上的真实 HTTP 服务器3-> Spring Security Filter Chain4-> HealthController5-> HealthCheckService6-> RemoteHealthClient 替身7-> HealthResultRecorder 事务8-> HealthStatusRepository9-> PostgreSQL 容器
它比 @WebMvcTest 更宽,因为会加载完整 ApplicationContext、启动服务器并访问真实数据库;它又没有启动真正的认证服务器和另一个业务服务,所以仍属于「当前应用的端到端集成测试」,不是跨越整套部署环境的系统测试。
不同测试解决不同问题,不需要互相替代:
| 测试 | 主要证明什么 | 失败后的定位速度 |
|---|---|---|
| 普通单元测试 | Java 业务分支与转换规则 | 最快 |
| MVC 或 JPA 切片 | 单个框架边界 | 较快 |
| 数据库集成测试 | SQL、映射、迁移与事务 | 中等 |
| 应用级端到端测试 | HTTP 到数据库的整体装配 | 较慢 |
端到端测试应该保留少量关键流程,细小分支继续由更窄的测试覆盖。否则一个失败会同时面对过多可能原因,执行时间也会快速增长。
2. 启动真实服务器
@SpringBootTest 默认使用模拟 Web 环境,不会监听端口。设置 RANDOM_PORT 后,Spring Boot 会启动真实服务器并自动选择未占用端口:
01@SpringBootTest(02webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,03properties = {04"app.monitoring.initial-delay=1h",05"app.monitoring.interval=1h"06}07)08@AutoConfigureRestTestClient09@Import(TestcontainersConfiguration.class)10class HealthCheckEndToEndTests {11}
@AutoConfigureRestTestClient 创建绑定到这个随机端口的 RestTestClient,测试只写相对路径,不需要读取端口并拼接 URL。它使用真实网络调用服务器,与基于模拟 Servlet 的 MockMvcTester 范围不同。
@Import(TestcontainersConfiguration.class) 复用前文定义的 PostgreSQL 容器和 @ServiceConnection。DataSource 与 Flyway 会连接临时数据库,生产数据库配置不会被使用。
测试将定时任务的首次延迟设置为一小时,避免测试期间后台调度碰巧写入数据。它没有关闭业务能力,只是把与本次断言无关的自动触发移出测试时间窗口。
项目已有的测试依赖可以继续使用:
01<dependency>02<groupId>org.springframework.boot</groupId>03<artifactId>spring-boot-starter-webmvc-test</artifactId>04<scope>test</scope>05</dependency>0607<dependency>08<groupId>org.springframework.boot</groupId>09<artifactId>spring-boot-testcontainers</artifactId>10<scope>test</scope>11</dependency>1213<dependency>14<groupId>org.testcontainers</groupId>15<artifactId>testcontainers-postgresql</artifactId>16<scope>test</scope>17</dependency>
这些依赖分别提供 Spring MVC 测试支持、Spring Boot 的容器服务连接以及 PostgreSQL 容器。若前面的章节已经加入,不要重复声明。
3. 控制外部系统边界
测试目标是当前应用,不应该因为认证服务器或被监控服务暂时不可访问而随机失败。使用 @MockitoBean 替换这两个边界 Bean,但保留 Security Filter Chain 和其余业务组件:
1@MockitoBean2private JwtDecoder jwtDecoder;34@MockitoBean5private RemoteHealthClient remoteHealthClient;
JwtDecoder 替身只负责把固定 Token 解码成测试身份,后续 Bearer Token Filter、Scope 转换和授权规则仍会真实执行。RemoteHealthClient 替身返回确定的探测结果;它自身的 URL、状态码和 JSON 协议已经由上一章的客户端测试负责。
在每个测试开始前定义外部系统行为:
01@BeforeEach02void setUp() {03jdbcClient.sql("delete from health_status where service = :service")04.param("service", "inventory")05.update();0607given(remoteHealthClient.probe(any()))08.willReturn(ProbeResult.up());0910Jwt jwt = Jwt.withTokenValue("monitor-token")11.header("alg", "none")12.subject("integration-test")13.claim("scope", "monitor")14.issuedAt(Instant.now())15.expiresAt(Instant.now().plusSeconds(300))16.build();1718given(jwtDecoder.decode("monitor-token")).willReturn(jwt);19}
Token 的 scope 是 monitor,会被默认转换为 SCOPE_monitor,与安全章节中的授权规则匹配。这里没有绕过安全 Filter,也没有把所有请求设成无条件认证成功。
4. 验证成功链路
完整测试类需要注入客户端和数据库查询工具:
001package cn.usehook.spring.health;002003import cn.usehook.spring.TestcontainersConfiguration;004import cn.usehook.spring.monitoring.ProbeResult;005import cn.usehook.spring.monitoring.RemoteHealthClient;006import java.time.Instant;007import org.junit.jupiter.api.AfterEach;008import org.junit.jupiter.api.BeforeEach;009import org.junit.jupiter.api.Test;010import org.springframework.beans.factory.annotation.Autowired;011import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;012import org.springframework.boot.test.context.SpringBootTest;013import org.springframework.context.annotation.Import;014import org.springframework.http.HttpHeaders;015import org.springframework.jdbc.core.simple.JdbcClient;016import org.springframework.security.oauth2.jwt.Jwt;017import org.springframework.security.oauth2.jwt.JwtDecoder;018import org.springframework.test.context.bean.override.mockito.MockitoBean;019import org.springframework.test.web.servlet.client.RestTestClient;020021import static org.assertj.core.api.Assertions.assertThat;022import static org.mockito.ArgumentMatchers.any;023import static org.mockito.BDDMockito.given;024import static org.mockito.BDDMockito.then;025026@SpringBootTest(027webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,028properties = {029"app.monitoring.initial-delay=1h",030"app.monitoring.interval=1h"031}032)033@AutoConfigureRestTestClient034@Import(TestcontainersConfiguration.class)035class HealthCheckEndToEndTests {036037private static final String SERVICE = "inventory";038039private final RestTestClient restTestClient;040private final JdbcClient jdbcClient;041042@MockitoBean043private JwtDecoder jwtDecoder;044045@MockitoBean046private RemoteHealthClient remoteHealthClient;047048@Autowired049HealthCheckEndToEndTests(050RestTestClient restTestClient,051JdbcClient jdbcClient052) {053this.restTestClient = restTestClient;054this.jdbcClient = jdbcClient;055}056057@BeforeEach058void setUp() {059deleteResult();060given(remoteHealthClient.probe(any()))061.willReturn(ProbeResult.up());062063Instant now = Instant.now();064Jwt jwt = Jwt.withTokenValue("monitor-token")065.header("alg", "none")066.subject("integration-test")067.claim("scope", "monitor")068.issuedAt(now)069.expiresAt(now.plusSeconds(300))070.build();071given(jwtDecoder.decode("monitor-token")).willReturn(jwt);072}073074@AfterEach075void tearDown() {076deleteResult();077}078079@Test080void checksConfiguredTargetAndPersistsResult() {081restTestClient.post()082.uri("/api/v1/health/check-configured")083.header(HttpHeaders.AUTHORIZATION, "Bearer monitor-token")084.exchange()085.expectStatus().isOk()086.expectBody()087.jsonPath("$.data.status").isEqualTo("UP")088.jsonPath("$.data.service").isEqualTo(SERVICE);089090String savedStatus = jdbcClient.sql("""091select status092from health_status093where service = :service094""")095.param("service", SERVICE)096.query(String.class)097.single();098099assertThat(savedStatus).isEqualTo("UP");100}101102private void deleteResult() {103jdbcClient.sql("delete from health_status where service = :service")104.param("service", SERVICE)105.update();106}107}
HTTP 断言验证安全认证、路由和响应 JSON,SQL 断言验证同一次请求确实提交到了 PostgreSQL。只断言响应并不能证明 Repository 被调用,只查询数据库又会漏掉路径、认证和序列化错误,两部分合在一起才覆盖目标链路。
5. 验证安全失败不会产生副作用
端到端测试还应该覆盖一条关键失败路径。请求不携带 Token 时,安全 Filter 应在 Controller 之前返回 401,数据库不应出现新记录:
01@Test02void rejectsRequestWithoutTokenBeforeBusinessExecution() {03restTestClient.post()04.uri("/api/v1/health/check-configured")05.exchange()06.expectStatus().isUnauthorized();0708long count = jdbcClient.sql("""09select count(*)10from health_status11where service = :service12""")13.param("service", SERVICE)14.query(Long.class)15.single();1617assertThat(count).isZero();18then(remoteHealthClient).shouldHaveNoInteractions();19}
这条测试不是再次证明 JwtDecoder 的实现,而是证明当前路径确实受到安全规则保护,而且认证失败不会触发远程调用与数据库写入。数据库计数和 Mockito 交互断言分别验证了两个副作用边界。
6. 事务与数据清理
不要给这个随机端口测试类添加 @Transactional 并期待自动回滚服务器写入。测试方法运行在测试线程,请求由服务器线程处理,两边不是同一个事务:
1测试线程2-> 发出 HTTP 请求34服务器线程5-> 开启 HealthResultRecorder 事务6-> 写入并提交 PostgreSQL78测试线程9-> 查询已经提交的数据
因此示例在 @BeforeEach 和 @AfterEach 中显式删除自己的数据。前置清理避免上次中断残留影响当前断言,后置清理保持容器内用例隔离。清理条件使用测试专属服务名,不能无差别删除整张共享表。
测试数据也可以通过唯一名称隔离,例如在每次测试中生成固定前缀加随机后缀。无论采用哪种方式,都要让清理策略与真正的提交边界一致,而不是依赖测试注解制造并不存在的事务关系。
Flyway 迁移发生在 ApplicationContext 初始化阶段,不应该在每个测试后回滚。测试复用已经迁移好的表结构,只清理自己创建的业务数据。
7. 保持测试稳定且可诊断
端到端测试需要额外控制时间和外部状态:
| 风险 | 处理方式 |
|---|---|
| 端口冲突 | 使用 RANDOM_PORT |
| 开发数据库被误用 | 通过 @ServiceConnection 注入容器连接 |
| 外部服务波动 | 在应用边界替换 JwtDecoder 与远程客户端 |
| 后台定时任务干扰 | 延长测试中的首次调度时间 |
| 已提交数据残留 | 使用测试专属键显式前后清理 |
| 等待时序不稳定 | 请求返回后直接查询提交结果,不使用任意 sleep |
执行测试:
1./mvnw -Dtest=HealthCheckEndToEndTests test
若测试失败,按照链路顺序查看 HTTP 状态、认证 Authority、Controller 映射、外部边界预设、事务日志和最终 SQL。应用集成到这里已经形成闭环;接下来的业务进阶会增加文件导入、资源权限、缓存、可靠告警、多实例调度与历史清理,再进入连接池、可观测性、容器部署与自动化发布等生产实践。