Spring测试:TestContext、MockMvc 与切片
测试不是"跑通了就是对的":每个测试只证明它真实经过的那条链。本文固定 Boot4.0.9 / Framework7.0.10 / JDK21,安全线为Security7.0.8;组件接线的条件与替换见集成正文,Security过滤链机制见Security原文,事务传播/提交分支见事务正文。全部观察来自boot-testing实验的真实运行,验收记录见 docs/audits/java-depth-u25-verification.md;JDK17/Framework6.1.12旧机制基线保留在 labs/spring-senior/,其MockMvc结论不在本版本线上复用。
测试要证明什么:三层证据与替身边界
一次提交里的测试至少分三层,各回答不同问题:单测(不起Spring)证明纯逻辑——算法、分支、边界;TestContext集成测试(真实 ApplicationContext)证明装配——Bean真的被创建、注入、后处理,条件真的按预期退让;真实依赖测试(真实端口、真实数据库/Redis)证明边界——传输、提交、服务端行为。层越靠右越贵越慢,所以框架提供了缓存、切片和替身三种省钱手段;省钱手段各自牺牲一部分证明力,混淆"省了什么"就是把绿测试当成了错误结论。
替身分级从弱到强:@MockitoBean 替换单个协作者(其余都是真的);切片只装配某一层(如 @WebMvcTest 只装MVC);完整上下文全装配但不监听端口(@SpringBootTest 默认MOCK环境);真实端口(RANDOM_PORT)连容器分派都是真的。判断某个测试证明了什么,先问它落在哪一层,再问这一层包含什么、排除了什么——下文逐层展开。
TestContext:缓存键由合并配置决定
@SpringBootTest 的第一个测试类会触发一次完整装配(秒级);第二个测试类若能命中缓存则直接复用同一个 ApplicationContext 实例。命中与否由合并配置(MergedContextConfiguration)完全决定——它是缓存的键,按值比较。原码:Framework v7.0.10 MergedContextConfiguration#equals 节选。
MergedContextConfiguration otherConfig = (MergedContextConfiguration) other;
if (!Arrays.equals(this.locations, otherConfig.locations)) {
return false;
}
if (!Arrays.equals(this.classes, otherConfig.classes)) {
return false;
}
if (!this.contextInitializerClasses.equals(otherConfig.contextInitializerClasses)) {
return false;
}
if (!Arrays.equals(this.activeProfiles, otherConfig.activeProfiles)) {
return false;
}
if (!this.propertySourceDescriptors.equals(otherConfig.propertySourceDescriptors)) {
return false;
}
if (!Arrays.equals(this.propertySourceProperties, otherConfig.propertySourceProperties)) {
return false;
}
if (!this.contextCustomizers.equals(otherConfig.contextCustomizers)) {
return false;
}这九项里与日常写测试直接相关的:classes(@SpringBootTest(classes=...) / @ContextConfiguration)、activeProfiles(@ActiveProfiles)、propertySourceProperties 与 propertySourceDescriptors(@TestPropertySource 的内联属性与文件位置)、contextCustomizers(@DynamicPropertySource、@MockitoBean 等都会经customizer进入键)。注意测试类本身不在键里——同一配置的任意多个测试类共享一个上下文。实验实测:同配置两个测试类只创建一次上下文且注入的是同一实例;@ActiveProfiles("u25") 与内联 @TestPropertySource 各自产生新上下文——四类探针、4次刷新,身份哈希两两不同。@DynamicPropertySource 的customizer按方法集合比较:两个类声明同一个动态属性方法则命中同一缓存,此时复用首次创建的上下文,动态属性不会重新求值——想在第二个类里拿到新的动态值,要么 @DirtiesContext,要么拆成不同的方法签名。
缓存有默认容量32、LRU驱逐(spring.test.context.cache.maxSize 可调),还有按键记录的加载失败计数(连续失败超过阈值直接跳过重复加载,DefaultCacheAwareContextLoaderDelegate 源码可见 failureThreshold)。缓存在JVM内是静态的:同一Surefire fork内所有模块测试共享,跨fork/跨模块各自独立。
上下文缓存的生命周期:命中、隔离与 @DirtiesContext
@DirtiesContext 不是"清理数据",是作废上下文:标脏的测试类自己先照常复用缓存实例,类结束后该上下文被关闭(发布 ContextClosedEvent)并从缓存移除;之后任何测试类再需要同一配置,必然重新装配出新实例。实验用事件登记器逐行断言了这条序列:共享配置创建(ctx@A)→ profile配置创建(ctx@B)→ property配置创建(ctx@C)→ 标脏类复用ctx@A、类后 ContextClosedEvent(ctx@A)→ 再用同配置,重建为ctx@D,全程4次刷新1次关闭,A与D身份不同。重建不是复活:单例状态、缓存的数据、@MockitoBean 的替身全部重来。
什么时候该标脏:测试污染了共享单例(改了无状态Bean的字段、往共享缓存里塞了测试数据)、或必须替换掉容器内不可恢复的资源。代价是这一配置的全量装配时间,滥用 @DirtiesContext 等于亲手杀死缓存——套件从"装配一次"退化成"每类装配一次"。"上下文隔离"在TestContext语境下指:配置不同自然得到不同上下文(键不同),配置相同就该共享(复用);隔离靠的是键的正确分化,而不是每类都标脏。
事务测试:默认回滚是测试框架的行为
给测试类(或方法)标 @Transactional 后,TransactionalTestExecutionListener 在每个测试方法前开启事务、方法后回滚——默认回滚,不是默认提交。原码:Framework v7.0.10 TransactionalTestExecutionListener#isDefaultRollback 节选。
@SuppressWarnings("NullAway")
protected final boolean isDefaultRollback(TestContext testContext) throws Exception {
Class<?> testClass = testContext.getTestClass();
Rollback rollback = TestContextAnnotationUtils.findMergedAnnotation(testClass, Rollback.class);
boolean rollbackPresent = (rollback != null);
if (rollbackPresent) {
boolean defaultRollback = rollback.value();
if (logger.isDebugEnabled()) {
logger.debug(String.format("Retrieved default @Rollback(%s) for test class [%s].",
defaultRollback, testClass.getName()));
}
return defaultRollback;
}
// else
return true;
}类级/方法级 @Rollback(false) 或 @Commit(就是 @Rollback(false) 的别名)关掉回滚。@JdbcTest 这类切片同样默认事务回滚——实验在JDBC切片里实测插入可见1、下一方法计数0。这个默认值是测试框架的便利,好处是测试之间互不留痕、可重复;它不是生产语义的一部分。
package study.testing.tx;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.UUID;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.annotation.Commit;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
/**
* 事务测试的真实回滚/提交对照:同一测试类内先验证默认回滚,再用 @Commit 走真实提交,
* 提交结果由池外全新 MySQL 连接核对。数据库名每次运行唯一,结束后整体 DROP。
*/
@SpringBootTest
@TestPropertySource(properties = "spring.datasource.hikari.maximum-pool-size=2")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@Transactional
class TransactionLifecycleTest {
static final String DB = "u25_" + UUID.randomUUID().toString().replace("-", "");
static final String BASE = "jdbc:mysql://127.0.0.1:3306/?sslMode=DISABLED&allowPublicKeyRetrieval=true";
@Autowired
JdbcTemplate jdbc;
@DynamicPropertySource
static void syntheticDatabase(DynamicPropertyRegistry registry) {
try (Connection connection = DriverManager.getConnection(BASE, "root", "");
Statement statement = connection.createStatement()) {
statement.executeUpdate("CREATE DATABASE IF NOT EXISTS `" + DB + "`");
statement.executeUpdate("CREATE TABLE IF NOT EXISTS `" + DB + "`.u25_tx_t (id INT PRIMARY KEY, val VARCHAR(64))");
} catch (SQLException e) {
throw new IllegalStateException("本机 MySQL 127.0.0.1:3306 root 空密码不可用或建库失败", e);
}
registry.add("spring.datasource.url", () -> "jdbc:mysql://127.0.0.1:3306/" + DB + "?sslMode=DISABLED&allowPublicKeyRetrieval=true");
registry.add("spring.datasource.username", () -> "root");
registry.add("spring.datasource.password", () -> "");
}
@Test
@Order(1)
void insertVisibleInsideOwnTransaction() {
jdbc.update("INSERT INTO u25_tx_t (id, val) VALUES (1, 'rollback-group')");
int inTx = jdbc.queryForObject("SELECT COUNT(*) FROM u25_tx_t WHERE val = 'rollback-group'", Integer.class);
org.junit.jupiter.api.Assertions.assertEquals(1, inTx, "未提交数据在同一事务内可见");
System.out.println("U25OBSERVE tx rollbackGroup visibleInTx=" + inTx);
}
@Test
@Order(2)
void rollbackHappenedAfterPreviousMethod() {
int afterRollback = jdbc.queryForObject("SELECT COUNT(*) FROM u25_tx_t WHERE val = 'rollback-group'", Integer.class);
org.junit.jupiter.api.Assertions.assertEquals(0, afterRollback, "上一个测试方法的事务必须已被测试框架回滚");
System.out.println("U25OBSERVE tx rollbackGroup absentAfterRollback=" + afterRollback);
}
@Test
@Order(3)
@Commit
void commitGroupWritesRealData() {
jdbc.update("INSERT INTO u25_tx_t (id, val) VALUES (2, 'commit-group')");
System.out.println("U25OBSERVE tx commitGroup committedByCommitAnnotation");
}
@Test
@Order(4)
void committedRowSurvivesOnFreshServerConnection() throws SQLException {
try (Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/" + DB + "?sslMode=DISABLED&allowPublicKeyRetrieval=true", "root", "");
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM u25_tx_t WHERE val = 'commit-group'")) {
rs.next();
org.junit.jupiter.api.Assertions.assertEquals(1, rs.getInt(1), "@Commit 的事务必须真实提交到 MySQL 服务器");
System.out.println("U25OBSERVE tx commitGroup visibleFromFreshServerConnection=" + rs.getInt(1));
}
}
@Test
@Order(5)
@Commit
void cleanupCommittedRow() {
int deleted = jdbc.update("DELETE FROM u25_tx_t WHERE val = 'commit-group'");
org.junit.jupiter.api.Assertions.assertEquals(1, deleted);
}
@AfterAll
static void dropSyntheticDatabase() throws SQLException {
try (Connection connection = DriverManager.getConnection(BASE, "root", "");
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery("SELECT COUNT(*) FROM `" + DB + "`.u25_tx_t")) {
rs.next();
org.junit.jupiter.api.Assertions.assertEquals(0, rs.getInt(1), "自建数据必须清理干净后再删库");
statement.executeUpdate("DROP DATABASE `" + DB + "`");
System.out.println("U25OBSERVE tx syntheticDatabaseDropped=" + DB);
}
}
}回滚不能证明提交:真实库对照
"回滚测试全绿"只证明了一件事:测试事务确实被回滚了。它不能证明生产提交路径工作——提交时才发生的网络中断、锁等待超时、二阶段协调失败、commit 调用本身抛异常,在回滚测试里一次都不会执行。把"测试通过"读成"持久化正确",等于把没走过的分支当成了已验证。
实验用同一张真实MySQL表做对照:默认回滚组插入后,同一事务内计数1(未提交可见),下一方法计数0(回滚生效);@Commit 组插入后,用连接池之外的全新DriverManager连接查到该行——这才是真实提交的服务器侧证据——再显式清理并删库。连真实库做事务测试的纪律:每次运行独有合成库名(u25_<uuid>),结束DROP前先断言无遗留行;凭据不同就如实失败,不猜。
提交路径的完整分支(同步/异步提交、异常与恢复)归事务正文的调用链与实验;本票只固定"测试回滚与生产提交是两条路"的责任边界。
MockMvc:三种装配,三种证明力
MockMvc是"调用真实 DispatcherServlet,但Servlet请求/响应对象是mock的"测试工具。三种装配方式的证明力完全不同:
- standalone:
MockMvcBuilders.standaloneSetup(controller)只装配你传入的控制器。实验实测:匿名访问受保护端点/api/hello得200,连授权端点/api/admin也是200——安全链、拦截器、全局异常处理统统缺席。它只证明映射与序列化。 - 切片(
@WebMvcTest,见下节):MVC相关组件在、其余缺席。 - 完整上下文(
@SpringBootTest+@AutoConfigureMockMvc):整个应用装配后,把MockMvc挂在真实上下文之上。
无论哪种装配,MockMvc都不经过:真实Servlet容器的接收/分派、TLS与网络栈、容器的FilterChain注册顺序、ERROR分派到容器错误页、连接超时与断连。DispatcherServlet、HandlerAdapter、消息转换这些MVC内部是真的。所以"MockMvc 200"证明的是"这个URL映射到这个处理器并成功返回",不是"部署后这个URL可达"。容器层边界(multipart上限、ERROR分派的真实行为)在U23以真实Tomcat补证过,见 docs/audits/java-depth-u23-verification.md;本票真实端口组(下节)再次给出同版本对照。
过滤器语义:addFilters 与安全链的在场
MockMvc本身没有安全概念;Boot的 @AutoConfigureMockMvc 通过 SpringBootMockMvcBuilderCustomizer 把上下文里登记的Servlet过滤器挂进MockMvc链,addFilters 默认true。于是"MockMvc会不会走安全过滤器"的准确答案分两步:第一步看装配方式(standalone根本没有过滤器);第二步看上下文里有没有安全过滤器Bean。
实验的web模块用同一应用实测了完整矩阵:@SpringBootTest+@AutoConfigureMockMvc 下匿名401、错口令401、正确用户200、USER访问OPS端点403、OPS用户200——安全链真实执行了(因为完整上下文里有应用的 SecurityFilterChain 与 springSecurityFilterChain 注册);@AutoConfigureMockMvc(addFilters=false) 同一端点匿名直接200——显式关闭过滤器注册就绕开整条链。对照真实端口(RANDOM_PORT 真实Tomcat)同一矩阵结果一致,且401响应带 WWW-Authenticate: Basic realm="Realm" 质询头。这说明:带过滤器的MockMvc与真实端口在应用层安全语义上同源(同一条链、同一套规则),差异只在传输层——MockMvc测不出的恰恰是Tomcat/TLS/网络那一段。
package study.testing.web;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
/**
* @SpringBootTest + @AutoConfigureMockMvc 默认 addFilters=true:
* 应用上下文里登记的 springSecurityFilterChain 参与每次 perform,401/403 与真实链一致。
*/
@SpringBootTest
@AutoConfigureMockMvc
class MockMvcFiltersTest {
@Autowired
MockMvc mvc;
static MockHttpServletRequestBuilder get(String path, String user, String password) {
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(path);
if (user != null) {
String token = Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
builder.header("Authorization", "Basic " + token);
}
return builder;
}
@Test
void securityFilterChainRunsInsideMockMvc() throws Exception {
int open = mvc.perform(get("/api/open", null, null)).andReturn().getResponse().getStatus();
int anonymous = mvc.perform(get("/api/hello", null, null)).andReturn().getResponse().getStatus();
int wrong = mvc.perform(get("/api/hello", "u25user", "wrong")).andReturn().getResponse().getStatus();
int user = mvc.perform(get("/api/hello", "u25user", "u25pass")).andReturn().getResponse().getStatus();
int forbidden = mvc.perform(get("/api/admin/report", "u25user", "u25pass")).andReturn().getResponse().getStatus();
int ops = mvc.perform(get("/api/admin/report", "u25ops", "u25pass")).andReturn().getResponse().getStatus();
org.junit.jupiter.api.Assertions.assertEquals(200, open);
org.junit.jupiter.api.Assertions.assertEquals(401, anonymous, "Boot 的 MockMvc 默认带安全过滤器:匿名必须被拒");
org.junit.jupiter.api.Assertions.assertEquals(401, wrong, "错误口令是认证失败");
org.junit.jupiter.api.Assertions.assertEquals(200, user);
org.junit.jupiter.api.Assertions.assertEquals(403, forbidden, "USER 角色访问 OPS 端点是授权失败");
org.junit.jupiter.api.Assertions.assertEquals(200, ops);
System.out.println("U25OBSERVE mockmvc-filters open=" + open + " anonymous=" + anonymous
+ " wrongPassword=" + wrong + " user=" + user + " userOnAdmin=" + forbidden + " ops=" + ops);
}
}Boot4 切片:装载范围与取舍
切片注解只装配"这一层"需要的自动配置,组件扫描也被收窄。原码:Boot v4.0.9 WebMvcTest javadoc节选——注意它同时声明了"默认自动配置Security与MockMvc"和"配合 @MockitoBean 补协作者"。
* By default, tests annotated with {@code @WebMvcTest} will also auto-configure Spring
* Security and {@link MockMvc} (include support for HtmlUnit WebClient and Selenium
* WebDriver). For more fine-grained control of MockMVC the
* {@link AutoConfigureMockMvc @AutoConfigureMockMvc} annotation can be used.
* <p>
* Typically {@code @WebMvcTest} is used in combination with
* {@link org.springframework.test.context.bean.override.mockito.MockitoBean @MockitoBean}
* or {@link Import @Import} to create any collaborators required by your
* {@code @Controller} beans.
* <p>
* If you are looking to load your full application configuration and use MockMVC, you
* should consider {@link SpringBootTest @SpringBootTest} combined with
* {@link AutoConfigureMockMvc @AutoConfigureMockMvc} rather than this annotation.@WebMvcTest 的扫描只收 @Controller/@ControllerAdvice 与 Converter、Filter、SecurityFilterChain、WebMvcConfigurer 等实现类;@Service 不被扫描。实验负例(入口里以退出码1如实登记):不给 @MockitoBean 补位时,控制器构造缺 OrderService,上下文加载直接失败(UnsatisfiedDependencyException);补位后切片内唯一的 OrderService 就是那个mock,JdbcTemplate 为0。
Boot4实测差异必须单独记:javadoc声称切片默认自动配置Security,但4.0.9的切片导入清单(AutoConfigureWebMvc.imports)不含任何安全自动配置——安全集成被拆到独立artifact spring-boot-security-test(其 AutoConfigureMockMvc.imports 贡献 SecurityAutoConfiguration/ServletWebSecurityAutoConfiguration 等)。实验两个模块同版本对照:只有starter-security时切片0条 SecurityFilterChain、匿名请求200直达控制器(静默放行);加上 spring-boot-security-test 后1条默认链、匿名401、mock身份源可完成认证。旧线(Boot3把安全切片支持烧进统一的spring-boot-test-autoconfigure)不能平移到Boot4。
@JdbcTest(包名 org.springframework.boot.jdbc.test.autoconfigure)装 JdbcTemplate 与事务管理器、不装web——实验实测JDBC切片JdbcTemplate=1、控制器与 DispatcherServlet=0,配 @AutoConfigureTestDatabase(replace=NONE) + @DynamicPropertySource 连真实MySQL,同事务可见1、下一方法回滚0。@DataRedisTest(org.springframework.boot.data.redis.test.autoconfigure)连本机Redis database13真实PING=PONG,自有key写读删、Web/JDBC组件为0。切片与完整上下文的取舍不是二选一:切片管装配正确性的快反馈,关键链路(安全、事务提交、就绪)仍要完整上下文与真实端口各测一遍;只跑切片就把"该层行为"误当"整条链"。
测试依赖与配置隔离
切片与集成测试的配置注入有三条梯度:@WebMvcTest(properties=...)/@TestPropertySource(properties=...) 内联属性(内联优先于其locations文件);@DynamicPropertySource 动态属性(求值时点在上下文创建前,能力是"运行期才算得出"的值,如随机库名);以及测试类外部的 application.properties/Profile。优先级关系与来源分层的完整规则见Boot配置正文。每个不同的属性集合都是新的缓存键——用属性做隔离的代价是上下文繁殖:实验里一个内联属性就多出一次全量装配。
测试依赖隔离的三个层次:嵌入式替换(@JdbcTest 默认把DataSource换成嵌入式库——快,但H2的行为推不出MySQL,见事务正文的边界声明);replace=NONE连真实共享实例(本票做法:独有合成库名/键前缀+定向清理,只动自己的资源);每测试一次性环境(Testcontainers,归后续票)。选哪层取决于要证明什么:装配与SQL语法嵌入式就够,锁行为/字符集/提交语义必须真实库。对共享本机服务,测试的最小责任是:可定向清理的命名(uuid前缀)、结束前断言无遗留、不动服务拓扑与全局配置。
替身不能证明的事:两个实测反例
反例一:mock的身份源。@MockitoBean UserDetailsService 替换身份源后,认证器链(DaoAuthenticationProvider、口令校验、过滤器)都是真实组件,mock返回的凭据会被真实校验——实验里mock口令认证成功200。但同一场测试里生产口令反而401:通过认证的是替身返回的凭据,不是生产身份源。"mock通过"证明的是认证器对这个假身份工作,认证器对生产身份源工作需要真实源的对照(本票真实端口组:无mock、真实UserDetailsService、同矩阵通过)。
package study.testing.web;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import static org.mockito.Mockito.when;
/**
* @MockitoBean 替换 UserDetailsService:认证器(Provider、口令校验)仍是真实组件,
* 但身份源是替身——mock 口令通过、生产口令反而失败,证明"mock 通过"不等于生产身份源工作。
*/
@SpringBootTest
@AutoConfigureMockMvc
class MockMvcMockedUsersTest {
@Autowired
MockMvc mvc;
@MockitoBean
UserDetailsService users;
static MockHttpServletRequestBuilder basic(String path, String user, String password) {
String token = Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
return MockMvcRequestBuilders.get(path).header("Authorization", "Basic " + token);
}
@Test
void mockIdentitySourceIsNotTheProductionOne() throws Exception {
when(users.loadUserByUsername("u25user"))
.thenReturn(User.withUsername("u25user").password("{noop}mock-pass").roles("USER").build());
int mockPassword = mvc.perform(basic("/api/hello", "u25user", "mock-pass")).andReturn().getResponse().getStatus();
int productionPassword = mvc.perform(basic("/api/hello", "u25user", "u25pass")).andReturn().getResponse().getStatus();
org.junit.jupiter.api.Assertions.assertEquals(200, mockPassword, "真实 Provider 对 mock 返回的凭据完成认证");
org.junit.jupiter.api.Assertions.assertEquals(401, productionPassword, "生产口令在 mock 身份源下必然失败:通过的是替身,不是生产源");
System.out.println("U25OBSERVE mocked-users mockPassword=" + mockPassword + " productionPassword=" + productionPassword);
}
}反例二:装配缺席的静默放行。Boot4裸切片匿名200(上节)最危险的地方在于测试是绿的:没有失败信号提示"安全根本没被测到"。同理standalone MockMvc对授权端点200。这两个反例共同说明:替身测试的通过条件里不包含"被替掉的那部分在生产也工作";门禁要靠矩阵对照(mock组+真实组)与明确的断言差异来兜住,而不是靠单个绿测试。
客户端测试:RestClient 的错误路径
服务端用MockMvc,客户端测试反过来:被测的是 RestClient 本身,"真实"意味着真实的HTTP往返——但目标服务可以是受控的。实验用JDK内置 HttpServer 在loopback起受控服务,实测四条路径:200正常返回;500抛 RestClientResponseException(getStatusCode()==500,且 exchange() 可手动接管错误码不抛异常);处理器故意sleep制造读超时,抛 ResourceAccessException,底层cause实测为 java.net.http.HttpTimeoutException;连接一个刚释放的端口,ResourceAccessException 的cause为 java.net.ConnectException。超时与连接拒绝路径只有真实socket才会发生——mock掉 RestClient 本身则什么都证明不了。
客户端测试的边界同样要说清:受控服务证明的是客户端对响应/异常的处理逻辑,不证明真实目标服务的语义(限流、幂等头、版本协商),更不证明网络中间层(代理、LB、TLS握手)。生产目标不可在测试中触碰——那是契约测试与预发环境的责任。
观测与排障:上下文数量怎么看
三个可落地的观察手段:一,注册 ApplicationListener<ContextRefreshedEvent/ContextClosedEvent> 的Bean并在静态登记器里记行——本票实验即用它把"几次创建/几次关闭/是否同实例"断言成确定序列;二,logging.level.org.springframework.test.context.cache=DEBUG 让框架自己打印命中统计(Retrieved/Storing ApplicationContext);三,给测试进程挂上下文计数断言(如套件末位汇总),把"上下文数量"变成会被CI拦住的硬指标。排障场景:"套件越来越慢"先数上下文——属性/Profile分化、@DirtiesContext 滥用、@MockitoBean 位置漂移都会繁殖缓存键,每个新键都是一次全量装配;超过32触发LRU驱逐后还会反复重建。测试失败还要看失败计数:同一键连续加载失败超过阈值会直接跳过,报错文本里带failure threshold提示,这是上下文装配问题而非用例问题。
架构师变化条件:测试预算与门禁
三个必须回答的预算问题:上下文数量预算——每个上下文秒级装配,模块内典型上限该按CI时间反推,超了就收敛属性分化和标脏;真实依赖比例——嵌入式/切片/真实库/真实端口各占多少,安全与提交这类高风险语义必须留真实组,纯mock组不得在门禁里冒充端到端;清理责任——共享本机服务的测试必须自证清理(独有命名、结束断言、DROP/DELETE自己的资源),泄漏的库和key会污染下一次运行,让观察混入旧状态。
版本升级时优先复验的正是本票实测过的三点:替身注解(Boot4已移除 @MockBean,用spring-test的 @MockitoBean——旧包路径在4.0.9是404);切片注解包名(全部迁到按技术拆分的新模块);切片安全集成(spring-boot-security-test 是否在类路径直接决定切片有没有安全链)。这些不是兼容性细节,是"测试还在不在测你以为的东西"的问题。Testcontainers/K8s内真实编排、响应式WebTestClient双模式(mock server与bindToServer真实链路)归U26/U27及后续票,不由本票loopback结论抵扣。
闭卷验收
本页 15 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。