Move `WebTestClientBuilderCustomizer` to `spring-boot-test`

Relocate `WebTestClientBuilderCustomizer` to `spring-boot-test`
and break the direct link to web-server and http-codec by making
use of `spring.factories` and the new `BaseUrlProviders` class.

A new `spring-boot-test-integration-test` module has also been
added to ensure hold the previous tests.

See gh-46356
This commit is contained in:
Phillip Webb 2025-09-27 22:32:44 -07:00
parent 79091f926d
commit 6f909114e7
28 changed files with 149 additions and 125 deletions

View File

@ -109,6 +109,10 @@
<allow pkg="org.springframework.boot.web.servlet" />
</subpackage>
</subpackage>
<subpackage name="client">
<allow pkg="jakarta.servlet" />
<allow pkg="org.springframework.context" />
</subpackage>
<subpackage name="context">
<allow pkg="org.springframework.context" />
</subpackage>

View File

@ -14,14 +14,17 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import org.springframework.context.ApplicationContext;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.reactive.server.WebTestClient.Builder;
/**
* A customizer that can be implemented by beans wishing to customize the {@link Builder}
* to fine-tine its auto-configuration before a {@link WebTestClient} is created.
* to fine-tune its auto-configuration before a {@link WebTestClient} is created.
* Implementations can be registered in the {@link ApplicationContext} or
* {@code spring.factories}.
*
* @author Andy Wilkinson
* @since 4.0.0

View File

@ -14,11 +14,11 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import java.util.Collection;
import java.util.ArrayList;
import java.util.List;
import jakarta.servlet.ServletContext;
import org.jspecify.annotations.Nullable;
import org.springframework.aot.AotDetector;
@ -28,31 +28,26 @@ import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.http.codec.CodecCustomizer;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.reactive.AbstractReactiveWebServerFactory;
import org.springframework.boot.test.http.server.BaseUrl;
import org.springframework.boot.test.http.server.BaseUrlProviders;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.ConfigurationClassPostProcessor;
import org.springframework.core.Ordered;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.core.io.support.SpringFactoriesLoader.ArgumentResolver;
import org.springframework.test.context.ContextCustomizer;
import org.springframework.test.context.MergedContextConfiguration;
import org.springframework.test.context.TestContextAnnotationUtils;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
/**
* {@link ContextCustomizer} for {@link WebTestClient}.
@ -61,13 +56,6 @@ import org.springframework.web.reactive.function.client.ExchangeStrategies;
*/
class WebTestClientContextCustomizer implements ContextCustomizer {
private static final boolean codecCustomizerPresent;
static {
ClassLoader loader = WebTestClientContextCustomizerFactory.class.getClassLoader();
codecCustomizerPresent = ClassUtils.isPresent("org.springframework.boot.http.codec.CodecCustomizer", loader);
}
@Override
public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) {
if (AotDetector.useGeneratedArtifacts()) {
@ -111,8 +99,7 @@ class WebTestClientContextCustomizer implements ContextCustomizer {
*/
static class WebTestClientRegistrar implements BeanDefinitionRegistryPostProcessor, Ordered, BeanFactoryAware {
@SuppressWarnings("NullAway.Init")
private BeanFactory beanFactory;
private @Nullable BeanFactory beanFactory;
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
@ -126,7 +113,7 @@ class WebTestClientContextCustomizer implements ContextCustomizer {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
if (AotDetector.useGeneratedArtifacts()) {
if (this.beanFactory == null || AotDetector.useGeneratedArtifacts()) {
return;
}
if (BeanFactoryUtils.beanNamesForTypeIncludingAncestors((ListableBeanFactory) this.beanFactory,
@ -134,7 +121,6 @@ class WebTestClientContextCustomizer implements ContextCustomizer {
registry.registerBeanDefinition(WebTestClient.class.getName(),
new RootBeanDefinition(WebTestClientFactory.class));
}
}
@Override
@ -148,15 +134,10 @@ class WebTestClientContextCustomizer implements ContextCustomizer {
*/
public static class WebTestClientFactory implements FactoryBean<WebTestClient>, ApplicationContextAware {
@SuppressWarnings("NullAway.Init")
private ApplicationContext applicationContext;
private @Nullable ApplicationContext applicationContext;
private @Nullable WebTestClient object;
private static final String SERVLET_APPLICATION_CONTEXT_CLASS = "org.springframework.web.context.WebApplicationContext";
private static final String REACTIVE_APPLICATION_CONTEXT_CLASS = "org.springframework.boot.web.context.reactive.ReactiveWebApplicationContext";
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
@ -181,87 +162,29 @@ class WebTestClientContextCustomizer implements ContextCustomizer {
}
private WebTestClient createWebTestClient() {
boolean sslEnabled = isSslEnabled(this.applicationContext);
String port = this.applicationContext.getEnvironment().getProperty("local.server.port", "8080");
String baseUrl = getBaseUrl(sslEnabled, port);
Assert.state(this.applicationContext != null, "ApplicationContext not injected");
WebTestClient.Builder builder = WebTestClient.bindToServer();
customizeWebTestClientBuilder(builder, this.applicationContext);
if (codecCustomizerPresent) {
WebTestClientCodecCustomizer.customizeWebTestClientCodecs(builder, this.applicationContext);
}
return builder.baseUrl(baseUrl).build();
}
private String getBaseUrl(boolean sslEnabled, String port) {
String basePath = deduceBasePath();
String pathSegment = (StringUtils.hasText(basePath)) ? basePath : "";
return (sslEnabled ? "https" : "http") + "://localhost:" + port + pathSegment;
}
private @Nullable String deduceBasePath() {
WebApplicationType webApplicationType = deduceFromApplicationContext(this.applicationContext.getClass());
if (webApplicationType == WebApplicationType.REACTIVE) {
return this.applicationContext.getEnvironment().getProperty("spring.webflux.base-path");
}
else if (webApplicationType == WebApplicationType.SERVLET) {
ServletContext servletContext = ((WebApplicationContext) this.applicationContext).getServletContext();
Assert.state(servletContext != null, "'servletContext' must not be null");
return servletContext.getContextPath();
}
return null;
}
static WebApplicationType deduceFromApplicationContext(Class<?> applicationContextClass) {
if (isAssignable(SERVLET_APPLICATION_CONTEXT_CLASS, applicationContextClass)) {
return WebApplicationType.SERVLET;
}
if (isAssignable(REACTIVE_APPLICATION_CONTEXT_CLASS, applicationContextClass)) {
return WebApplicationType.REACTIVE;
}
return WebApplicationType.NONE;
}
private static boolean isAssignable(String target, Class<?> type) {
try {
return ClassUtils.resolveClassName(target, null).isAssignableFrom(type);
}
catch (Throwable ex) {
return false;
}
}
private boolean isSslEnabled(ApplicationContext context) {
try {
AbstractReactiveWebServerFactory webServerFactory = context
.getBean(AbstractReactiveWebServerFactory.class);
return webServerFactory.getSsl() != null && webServerFactory.getSsl().isEnabled();
}
catch (NoSuchBeanDefinitionException ex) {
return false;
BaseUrl baseUrl = new BaseUrlProviders(this.applicationContext).getBaseUrl();
if (baseUrl != null) {
builder.baseUrl(baseUrl.resolve());
}
return builder.build();
}
private void customizeWebTestClientBuilder(WebTestClient.Builder clientBuilder, ApplicationContext context) {
for (WebTestClientBuilderCustomizer customizer : context
.getBeansOfType(WebTestClientBuilderCustomizer.class)
.values()) {
customizer.customize(clientBuilder);
}
Assert.state(this.applicationContext != null, "ApplicationContext not injected");
getWebTestClientBuilderCustomizers(this.applicationContext)
.forEach((customizer) -> customizer.customize(clientBuilder));
}
private static final class WebTestClientCodecCustomizer {
private static void customizeWebTestClientCodecs(WebTestClient.Builder clientBuilder,
ApplicationContext context) {
Collection<CodecCustomizer> codecCustomizers = context.getBeansOfType(CodecCustomizer.class).values();
if (!CollectionUtils.isEmpty(codecCustomizers)) {
clientBuilder.exchangeStrategies(ExchangeStrategies.builder()
.codecs((codecs) -> codecCustomizers
.forEach((codecCustomizer) -> codecCustomizer.customize(codecs)))
.build());
}
}
private List<WebTestClientBuilderCustomizer> getWebTestClientBuilderCustomizers(ApplicationContext context) {
List<WebTestClientBuilderCustomizer> customizers = new ArrayList<>();
SpringFactoriesLoader.forDefaultResourceLocation(context.getClassLoader())
.load(WebTestClientBuilderCustomizer.class, ArgumentResolver.of(ApplicationContext.class, context))
.forEach(customizers::add);
context.getBeansOfType(WebTestClientBuilderCustomizer.class).values().forEach(customizers::add);
return customizers;
}
}

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import java.util.List;

View File

@ -19,6 +19,6 @@
* {@link org.springframework.test.web.reactive.server.WebTestClient}.
*/
@NullMarked
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import org.jspecify.annotations.NullMarked;

View File

@ -4,7 +4,8 @@ org.springframework.boot.test.context.ImportsContextCustomizerFactory,\
org.springframework.boot.test.context.PropertyMappingContextCustomizerFactory,\
org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizerFactory,\
org.springframework.boot.test.context.filter.annotation.TypeExcludeFiltersContextCustomizerFactory,\
org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory
org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory,\
org.springframework.boot.test.web.reactive.client.WebTestClientContextCustomizerFactory
# Application Context Initializers
org.springframework.context.ApplicationContextInitializer=\

View File

@ -17,7 +17,7 @@
package org.springframework.boot.docs.testing.springbootapplications.autoconfiguredspringrestdocs.withwebtestclient;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.web.server.test.client.reactive.WebTestClientBuilderCustomizer;
import org.springframework.boot.test.web.reactive.client.WebTestClientBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import static org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation.document;

View File

@ -17,7 +17,7 @@
package org.springframework.boot.docs.testing.springbootapplications.autoconfiguredspringrestdocs.withwebtestclient
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.web.server.test.client.reactive.WebTestClientBuilderCustomizer
import org.springframework.boot.test.web.reactive.client.WebTestClientBuilderCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentation
import org.springframework.test.web.reactive.server.WebTestClient

View File

@ -0,0 +1,33 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the License);
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id "java-library"
}
description = "Spring Boot Test Integration Tests"
dependencies {
testImplementation(project(":core:spring-boot-test"))
testImplementation(project(":test-support:spring-boot-test-support"))
testImplementation(project(":module:spring-boot-http-codec"))
testImplementation(project(":module:spring-boot-tomcat"))
testImplementation(project(":module:spring-boot-web-server"))
testImplementation("io.projectreactor.netty:reactor-netty-http")
testImplementation("org.springframework:spring-webmvc")
testImplementation("org.springframework:spring-webflux")
}

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import java.util.Collections;
import java.util.Map;

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import org.junit.jupiter.api.Test;
@ -23,7 +23,7 @@ import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.web.server.test.client.reactive.WebTestClientContextCustomizer.WebTestClientRegistrar;
import org.springframework.boot.test.web.reactive.client.WebTestClientContextCustomizer.WebTestClientRegistrar;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.support.AbstractApplicationContext;
import org.springframework.test.context.MergedContextConfiguration;

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import java.util.Collections;
import java.util.Map;

View File

@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.boot.web.server.test.client.reactive;
package org.springframework.boot.test.web.reactive.client;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;

View File

@ -29,6 +29,7 @@ dependencies {
api("org.springframework:spring-web")
optional(project(":core:spring-boot-autoconfigure"))
optional(project(":core:spring-boot-test"))
optional(project(":module:spring-boot-jackson"))
optional("org.springframework:spring-webflux")

View File

@ -0,0 +1,55 @@
/*
* Copyright 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.http.codec;
import java.util.Collection;
import java.util.function.Consumer;
import org.springframework.boot.test.web.reactive.client.WebTestClientBuilderCustomizer;
import org.springframework.context.ApplicationContext;
import org.springframework.http.codec.ClientCodecConfigurer;
import org.springframework.test.web.reactive.server.WebTestClient.Builder;
import org.springframework.util.CollectionUtils;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
/**
* {@link WebTestClientBuilderCustomizer} to apply {@link CodecCustomizer} beans.
*
* @author Phillip Webb
*/
class CodecWebTestClientBuilderCustomizer implements WebTestClientBuilderCustomizer {
private final ApplicationContext context;
CodecWebTestClientBuilderCustomizer(ApplicationContext context) {
this.context = context;
}
@Override
public void customize(Builder builder) {
Collection<CodecCustomizer> customizers = this.context.getBeansOfType(CodecCustomizer.class).values();
if (!CollectionUtils.isEmpty(customizers)) {
ExchangeStrategies strategies = ExchangeStrategies.builder().codecs(apply(customizers)).build();
builder.exchangeStrategies(strategies);
}
}
private Consumer<ClientCodecConfigurer> apply(Collection<CodecCustomizer> customizers) {
return (codecs) -> customizers.forEach((customizer) -> customizer.customize(codecs));
}
}

View File

@ -0,0 +1,3 @@
# WebTestClient Builder Customizers
org.springframework.boot.test.web.reactive.server.WebTestClientBuilderCustomizer=\
org.springframework.boot.http.codec.CodecWebTestClientBuilderCustomizer

View File

@ -18,7 +18,7 @@ package org.springframework.boot.restdocs.test.autoconfigure;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.web.server.test.client.reactive.WebTestClientBuilderCustomizer;
import org.springframework.boot.test.web.reactive.client.WebTestClientBuilderCustomizer;
import org.springframework.restdocs.webtestclient.WebTestClientRestDocumentationConfigurer;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.StringUtils;

View File

@ -6,5 +6,4 @@ org.springframework.boot.web.server.test.SpringBootTestRandomPortEnvironmentPost
org.springframework.test.context.ContextCustomizerFactory=\
org.springframework.boot.web.server.test.client.RestTestClientContextCustomizerFactory,\
org.springframework.boot.web.server.test.client.TestRestTemplateContextCustomizerFactory,\
org.springframework.boot.web.server.test.client.reactive.WebTestClientContextCustomizerFactory,\
org.springframework.boot.web.server.test.reactor.netty.DisableReactorResourceFactoryGlobalResourcesContextCustomizerFactory

View File

@ -29,6 +29,7 @@ dependencies {
api("org.springframework:spring-web")
optional(project(":core:spring-boot-autoconfigure"))
optional(project(":core:spring-boot-test"))
optional("io.projectreactor:reactor-core")
optional("jakarta.servlet:jakarta.servlet-api")
optional("org.springframework:spring-test")

View File

@ -23,7 +23,7 @@ import java.util.function.Consumer;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.http.codec.CodecCustomizer;
import org.springframework.boot.web.server.test.client.reactive.WebTestClientBuilderCustomizer;
import org.springframework.boot.test.web.reactive.client.WebTestClientBuilderCustomizer;
import org.springframework.http.codec.ClientCodecConfigurer;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.web.reactive.server.WebTestClient.Builder;

View File

@ -26,7 +26,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.http.codec.CodecCustomizer;
import org.springframework.boot.web.server.test.client.reactive.WebTestClientBuilderCustomizer;
import org.springframework.boot.test.web.reactive.client.WebTestClientBuilderCustomizer;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.test.web.reactive.server.MockServerConfigurer;

View File

@ -24,9 +24,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.web.reactive.client.WebTestClientBuilderCustomizer;
import org.springframework.boot.test.web.servlet.client.RestTestClientBuilderCustomizer;
import org.springframework.boot.web.server.autoconfigure.ServerProperties;
import org.springframework.boot.web.server.test.client.RestTestClientBuilderCustomizer;
import org.springframework.boot.web.server.test.client.reactive.WebTestClientBuilderCustomizer;
import org.springframework.boot.webmvc.autoconfigure.DispatcherServletPath;
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
import org.springframework.boot.webmvc.autoconfigure.WebMvcProperties;

View File

@ -21,8 +21,8 @@ import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.test.web.reactive.client.WebTestClientBuilderCustomizer;
import org.springframework.boot.web.server.test.client.RestTestClientBuilderCustomizer;
import org.springframework.boot.web.server.test.client.reactive.WebTestClientBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.web.reactive.server.WebTestClient;

View File

@ -405,6 +405,7 @@ include ":integration-test:spring-boot-launch-script-integration-tests"
include ":integration-test:spring-boot-loader-integration-tests"
include ":integration-test:spring-boot-server-integration-tests"
include ":integration-test:spring-boot-sni-integration-tests"
include ":integration-test:spring-boot-test-integration-tests"
include ":system-test:spring-boot-deployment-system-tests"
include ":system-test:spring-boot-image-system-tests"