跳到主内容
创建时间: 2026-09-16最后更新: 2026-10-04

前面的测试分别验证了 Service、MVC、Repository、事务和 RestClient。这些测试定位问题快,却没有证明所有组件装配到一起后仍能完成一次真实请求。例如安全 Filter、JSON 转换、业务事务和数据库迁移可能各自正确,组合时仍可能因为 Bean、配置或路径不一致而失败。

本章创建应用级端到端集成测试:启动真实 Web 服务器,监听随机端口,从 HTTP 请求进入应用,经过 Spring Security、Controller、Service 和 Repository,最后写入 Testcontainers 提供的 PostgreSQL。身份提供方与被监控服务不属于当前应用,测试会在它们的边界提供可控替身。

1. 这次要验证多大的范围

应用级端到端测试覆盖以下路径:

end-to-end-scope.txt
1
RestTestClient
2
-> 随机端口上的真实 HTTP 服务器
3
-> Spring Security Filter Chain
4
-> HealthController
5
-> HealthCheckService
6
-> RemoteHealthClient 替身
7
-> HealthResultRecorder 事务
8
-> HealthStatusRepository
9
-> PostgreSQL 容器

它比 @WebMvcTest 更宽,因为会加载完整 ApplicationContext、启动服务器并访问真实数据库;它又没有启动真正的认证服务器和另一个业务服务,所以仍属于「当前应用的端到端集成测试」,不是跨越整套部署环境的系统测试。

不同测试解决不同问题,不需要互相替代:

测试主要证明什么失败后的定位速度
普通单元测试Java 业务分支与转换规则最快
MVC 或 JPA 切片单个框架边界较快
数据库集成测试SQL、映射、迁移与事务中等
应用级端到端测试HTTP 到数据库的整体装配较慢

端到端测试应该保留少量关键流程,细小分支继续由更窄的测试覆盖。否则一个失败会同时面对过多可能原因,执行时间也会快速增长。

2. 启动真实服务器

@SpringBootTest 默认使用模拟 Web 环境,不会监听端口。设置 RANDOM_PORT 后,Spring Boot 会启动真实服务器并自动选择未占用端口:

src/test/java/cn/usehook/spring/health/HealthCheckEndToEndTests.java
01
@SpringBootTest(
02
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
03
properties = {
04
"app.monitoring.initial-delay=1h",
05
"app.monitoring.interval=1h"
06
}
07
)
08
@AutoConfigureRestTestClient
09
@Import(TestcontainersConfiguration.class)
10
class HealthCheckEndToEndTests {
11
}

@AutoConfigureRestTestClient 创建绑定到这个随机端口的 RestTestClient,测试只写相对路径,不需要读取端口并拼接 URL。它使用真实网络调用服务器,与基于模拟 Servlet 的 MockMvcTester 范围不同。

@Import(TestcontainersConfiguration.class) 复用前文定义的 PostgreSQL 容器和 @ServiceConnection。DataSource 与 Flyway 会连接临时数据库,生产数据库配置不会被使用。

测试将定时任务的首次延迟设置为一小时,避免测试期间后台调度碰巧写入数据。它没有关闭业务能力,只是把与本次断言无关的自动触发移出测试时间窗口。

项目已有的测试依赖可以继续使用:

pom.xml
01
<dependency>
02
<groupId>org.springframework.boot</groupId>
03
<artifactId>spring-boot-starter-webmvc-test</artifactId>
04
<scope>test</scope>
05
</dependency>
06
07
<dependency>
08
<groupId>org.springframework.boot</groupId>
09
<artifactId>spring-boot-testcontainers</artifactId>
10
<scope>test</scope>
11
</dependency>
12
13
<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 和其余业务组件:

HealthCheckEndToEndTests.java
1
@MockitoBean
2
private JwtDecoder jwtDecoder;
3
4
@MockitoBean
5
private RemoteHealthClient remoteHealthClient;

JwtDecoder 替身只负责把固定 Token 解码成测试身份,后续 Bearer Token Filter、Scope 转换和授权规则仍会真实执行。RemoteHealthClient 替身返回确定的探测结果;它自身的 URL、状态码和 JSON 协议已经由上一章的客户端测试负责。

在每个测试开始前定义外部系统行为:

HealthCheckEndToEndTests.java
01
@BeforeEach
02
void setUp() {
03
jdbcClient.sql("delete from health_status where service = :service")
04
.param("service", "inventory")
05
.update();
06
07
given(remoteHealthClient.probe(any()))
08
.willReturn(ProbeResult.up());
09
10
Jwt 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();
17
18
given(jwtDecoder.decode("monitor-token")).willReturn(jwt);
19
}

Token 的 scope 是 monitor,会被默认转换为 SCOPE_monitor,与安全章节中的授权规则匹配。这里没有绕过安全 Filter,也没有把所有请求设成无条件认证成功。

4. 验证成功链路

完整测试类需要注入客户端和数据库查询工具:

src/test/java/cn/usehook/spring/health/HealthCheckEndToEndTests.java
001
package cn.usehook.spring.health;
002
003
import cn.usehook.spring.TestcontainersConfiguration;
004
import cn.usehook.spring.monitoring.ProbeResult;
005
import cn.usehook.spring.monitoring.RemoteHealthClient;
006
import java.time.Instant;
007
import org.junit.jupiter.api.AfterEach;
008
import org.junit.jupiter.api.BeforeEach;
009
import org.junit.jupiter.api.Test;
010
import org.springframework.beans.factory.annotation.Autowired;
011
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
012
import org.springframework.boot.test.context.SpringBootTest;
013
import org.springframework.context.annotation.Import;
014
import org.springframework.http.HttpHeaders;
015
import org.springframework.jdbc.core.simple.JdbcClient;
016
import org.springframework.security.oauth2.jwt.Jwt;
017
import org.springframework.security.oauth2.jwt.JwtDecoder;
018
import org.springframework.test.context.bean.override.mockito.MockitoBean;
019
import org.springframework.test.web.servlet.client.RestTestClient;
020
021
import static org.assertj.core.api.Assertions.assertThat;
022
import static org.mockito.ArgumentMatchers.any;
023
import static org.mockito.BDDMockito.given;
024
import static org.mockito.BDDMockito.then;
025
026
@SpringBootTest(
027
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
028
properties = {
029
"app.monitoring.initial-delay=1h",
030
"app.monitoring.interval=1h"
031
}
032
)
033
@AutoConfigureRestTestClient
034
@Import(TestcontainersConfiguration.class)
035
class HealthCheckEndToEndTests {
036
037
private static final String SERVICE = "inventory";
038
039
private final RestTestClient restTestClient;
040
private final JdbcClient jdbcClient;
041
042
@MockitoBean
043
private JwtDecoder jwtDecoder;
044
045
@MockitoBean
046
private RemoteHealthClient remoteHealthClient;
047
048
@Autowired
049
HealthCheckEndToEndTests(
050
RestTestClient restTestClient,
051
JdbcClient jdbcClient
052
) {
053
this.restTestClient = restTestClient;
054
this.jdbcClient = jdbcClient;
055
}
056
057
@BeforeEach
058
void setUp() {
059
deleteResult();
060
given(remoteHealthClient.probe(any()))
061
.willReturn(ProbeResult.up());
062
063
Instant now = Instant.now();
064
Jwt 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();
071
given(jwtDecoder.decode("monitor-token")).willReturn(jwt);
072
}
073
074
@AfterEach
075
void tearDown() {
076
deleteResult();
077
}
078
079
@Test
080
void checksConfiguredTargetAndPersistsResult() {
081
restTestClient.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);
089
090
String savedStatus = jdbcClient.sql("""
091
select status
092
from health_status
093
where service = :service
094
""")
095
.param("service", SERVICE)
096
.query(String.class)
097
.single();
098
099
assertThat(savedStatus).isEqualTo("UP");
100
}
101
102
private void deleteResult() {
103
jdbcClient.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,数据库不应出现新记录:

HealthCheckEndToEndTests.java
01
@Test
02
void rejectsRequestWithoutTokenBeforeBusinessExecution() {
03
restTestClient.post()
04
.uri("/api/v1/health/check-configured")
05
.exchange()
06
.expectStatus().isUnauthorized();
07
08
long count = jdbcClient.sql("""
09
select count(*)
10
from health_status
11
where service = :service
12
""")
13
.param("service", SERVICE)
14
.query(Long.class)
15
.single();
16
17
assertThat(count).isZero();
18
then(remoteHealthClient).shouldHaveNoInteractions();
19
}

这条测试不是再次证明 JwtDecoder 的实现,而是证明当前路径确实受到安全规则保护,而且认证失败不会触发远程调用与数据库写入。数据库计数和 Mockito 交互断言分别验证了两个副作用边界。

6. 事务与数据清理

不要给这个随机端口测试类添加 @Transactional 并期待自动回滚服务器写入。测试方法运行在测试线程,请求由服务器线程处理,两边不是同一个事务:

e2e-transactions.txt
1
测试线程
2
-> 发出 HTTP 请求
3
4
服务器线程
5
-> 开启 HealthResultRecorder 事务
6
-> 写入并提交 PostgreSQL
7
8
测试线程
9
-> 查询已经提交的数据

因此示例在 @BeforeEach 和 @AfterEach 中显式删除自己的数据。前置清理避免上次中断残留影响当前断言,后置清理保持容器内用例隔离。清理条件使用测试专属服务名,不能无差别删除整张共享表。

测试数据也可以通过唯一名称隔离,例如在每次测试中生成固定前缀加随机后缀。无论采用哪种方式,都要让清理策略与真正的提交边界一致,而不是依赖测试注解制造并不存在的事务关系。

Flyway 迁移发生在 ApplicationContext 初始化阶段,不应该在每个测试后回滚。测试复用已经迁移好的表结构,只清理自己创建的业务数据。

7. 保持测试稳定且可诊断

端到端测试需要额外控制时间和外部状态:

风险处理方式
端口冲突使用 RANDOM_PORT
开发数据库被误用通过 @ServiceConnection 注入容器连接
外部服务波动在应用边界替换 JwtDecoder 与远程客户端
后台定时任务干扰延长测试中的首次调度时间
已提交数据残留使用测试专属键显式前后清理
等待时序不稳定请求返回后直接查询提交结果,不使用任意 sleep

执行测试:

terminal.bash
1
./mvnw -Dtest=HealthCheckEndToEndTests test

若测试失败,按照链路顺序查看 HTTP 状态、认证 Authority、Controller 映射、外部边界预设、事务日志和最终 SQL。应用集成到这里已经形成闭环;接下来的业务进阶会增加文件导入、资源权限、缓存、可靠告警、多实例调度与历史清理,再进入连接池、可观测性、容器部署与自动化发布等生产实践。