Description

There is a design flaw in customizing the @RestClientTest auto-configuration MockRestServiceServerAutoConfiguration. You cannot add your own RestClientCustomizer bean when using @RestClientTest in a scenario where you inject the MockRestServiceServer bean in your test.

The POC below creates a RestClient with a content interceptor that consumes the response body twice. The test executes a request with the RestClient. The test fails because buffered content is disabled, despite it being enabled in the test via a custom RestClientCustomizer bean.

The POC provides two different custom bean injection examples demonstrating the same effect. In particular, the second example is interesting as I found no way to workaround the problem. Hence the issue here.

I suggest to improve the Spring-Boot configuration at least for the second case. See proposal and ideas section.

Executable POC

Here you find an executable Kotlin POC demonstrating the problem (Update: Java variant)

The POC creates a RestClient with a HTTP content interceptor that consumes the response body twice. You would expect the test to pass, because it configures a RestClientCustomizer with buffered content enabled. But it fails.

Enhancement Proposal and Ideas (for Discussion)

These proposals are based on the analysis why the injection of the custom bean does not work. Read the POC code first.

  • Make ObjectProvider<RestClientCustomizer> customizerProvider in RestClientAutoConfiguration.restClientBuilderConfigurer(...) order aware
    • i.e. implement Ordered
    • add @Order(0) and use AnnotationAwareOrderComparator for ObjectProvider<RestClientCustomizer> customizerProvider
  • Other?

Details

  • Spring Boot Version: 3.5.4

Analysis

This section is copy-paste from the provided sample code in case I ever delete the sample code from my Github account.

Overriding Bean Approach

Observation:

  • Our custom bean is overridden by the default bean provided by MockRestServiceServerAutoConfiguration

Analysis:

  • Our bean loads first, then does the MockRestServiceServerAutoConfiguration load its bean and overrides our bean

Log file evidence:

2025-08-15T10:24:56.830+02:00  INFO 53320 --- [spring-boot-rest-client-test-customize-issue] [    Test worker] o.s.b.f.s.DefaultListableBeanFactory     : Overriding bean definition for bean 'mockServerRestClientCustomizer' with a different definition: replacing [Root bean: class=null; scope=; abstract=false; lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=com.example.poc.BufferingRestClientTestConfiguration; factoryMethodName=mockServerRestClientCustomizer; initMethodNames=null; destroyMethodNames=[(inferred)]; defined in class path resource [com/example/poc/BufferingRestClientTestConfiguration.class]] with [Root bean: class=null; scope=; abstract=false; lazyInit=null; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; fallback=false; factoryBeanName=org.springframework.boot.test.autoconfigure.web.client.MockRestServiceServerAutoConfiguration; factoryMethodName=mockServerRestClientCustomizer; initMethodNames=null; destroyMethodNames=[(inferred)]; defined in class path resource [org/springframework/boot/test/autoconfigure/web/client/MockRestServiceServerAutoConfiguration.class]]

Workaround:

  • Override the RestClientCustomizer bean and manipulate the context loading order in the test class (see RestClientServiceTest). This works, but is not Spring-Boot like IMHO.

Sample Code:

@TestConfiguration
private class BufferingRestClientTestConfiguration {

    // Overrides bean from MockRestServiceServerAutoConfiguration
    @Bean
    fun mockServerRestClientCustomizer(): MockServerRestClientCustomizer {
        val customizer = MockServerRestClientCustomizer()
        customizer.setBufferContent(true)
        return customizer
    }
}

@RestClientTest(
    value = [RestClientService::class],
    properties = [
        "my-client.base-url=http://localhost",
        // Allow mockServerRestClientCustomizer override bean. Fails otherwise.
        "spring.main.allow-bean-definition-overriding=true",
    ],
)
@Import(
    ContentInterceptor::class,
    // Workaround: Load our bean after the auto-configuration. Seems not like a Spring-Boot like way (see POC README.md)
//    MockRestServiceServerAutoConfiguration::class,
    BufferingRestClientTestConfiguration::class,
)
internal class RestClientServiceTest @Autowired constructor(
    private val mockRestServiceServer: MockRestServiceServer,
    // This bean is not the one from BufferingRestClientTestConfiguration that has buffering enabled
    // private val mockServerRestClientCustomizer: MockServerRestClientCustomizer,
    private val unitUnderTest: RestClientService,
) {

    @Test
    fun testInterceptedRequest() {
        mockRestServiceServer
            .expect(
                requestTo("http://localhost/foo"),
            )
            .andExpect(method(HttpMethod.GET))
            .andRespond(withSuccess("hello world", MediaType.TEXT_PLAIN))

        val actual = unitUnderTest.retrieveFoo()

        asserter.assertEquals(null, "hello world", actual)
    }
}

Additional Bean Approach

Observation:

  • MockRestServiceServerAutoConfiguration consumes our bean due to the @Primary annotation
  • RestClientAutoConfiguration has both beans in ObjectProvider<RestClientCustomizer> customizerProvider, but the default auto-configuration bean is the second one and thus takes precedence over our bean

Analysis:

  • The MockServerRestClientCustomizer does not implement anything. In consequence, it becomes MAX_VALUE and takes lowest precedence. Hence, you cannot provide a bean with a lower precedence to have it last in the list

Context for the analysis:

// File RestClientAutoConfiguration
   @Bean
    @ConditionalOnMissingBean
    RestClientBuilderConfigurer restClientBuilderConfigurer(
          ObjectProvider<ClientHttpRequestFactoryBuilder<?>> clientHttpRequestFactoryBuilder,
          ObjectProvider<ClientHttpRequestFactorySettings> clientHttpRequestFactorySettings,
          ObjectProvider<RestClientCustomizer> customizerProvider) {
       return new RestClientBuilderConfigurer(
             clientHttpRequestFactoryBuilder.getIfAvailable(ClientHttpRequestFactoryBuilder::detect),
             clientHttpRequestFactorySettings.getIfAvailable(ClientHttpRequestFactorySettings::defaults),
              // Ordered stream here
             customizerProvider.orderedStream().toList());
    }

Workaround:

  • There is no workaround known to me to make this approach work

Sample Code:

@TestConfiguration
private class BufferingRestClientAdditionalBeanTestConfiguration {

    // Provide additional customizer bean
    @Bean
    @Primary
    fun myMockServerRestClientCustomizer(): MockServerRestClientCustomizer {
        val customizer = MockServerRestClientCustomizer()
        customizer.setBufferContent(true)
        return customizer
    }
}

@RestClientTest(
    value = [RestClientService::class],
    properties = [
        "my-client.base-url=http://localhost",
    ],
)
@Import(
    ContentInterceptor::class,
    BufferingRestClientAdditionalBeanTestConfiguration::class
)
internal class RestClientServiceMultipleCustomizerTest @Autowired constructor(
    private val mockRestServiceServer: MockRestServiceServer,
    // This bean is not the one from BufferingRestClientAdditionalBeanTestConfiguration that has buffering enabled
    // private val mockServerRestClientCustomizer: MockServerRestClientCustomizer,
    private val unitUnderTest: RestClientService,
) {

    @Test
    fun testInterceptedRequest() {
        mockRestServiceServer
            .expect(
                requestTo("http://localhost/foo"),
            )
            .andExpect(method(HttpMethod.GET))
            .andRespond(withSuccess("hello world", MediaType.TEXT_PLAIN))

        val actual = unitUnderTest.retrieveFoo()

        asserter.assertEquals(null, "hello world", actual)
    }
}

Comment From: philwebb

@Tooa Did you use AI to generate this issue? It's quite verbose and hard to understand the actual problem you're facing.

Could you please add a description of the actual problem you're facing (without any suggested workarounds) and provide a Java sample that also shows just the problem.

Comment From: Tooa

@Tooa Did you use AI to generate this issue? It's quite verbose and hard to understand the actual problem you're facing.

No. Quite the contrary, I invested quite some time to prepare the executable code POC for you with explanations.

Could you please add a description of the actual problem you're facing (without any suggested workarounds)

Let me try to phrase the problem simpler. The goal is to enable buffered content for a @RestClientTest. I tried two different approaches (see original issue). In both cases, the MockServerRestClientCustomizer being taken is the one from the default auto-configuration MockRestServiceServerAutoConfiguration with buffered content disabled.

You see this, when you execute one of the tests from the POC (e.g. RestClientServiceMultipleCustomizerTest) in the code repository I linked. The test executes a request with a RestClient that consumes the response body twice.

The test fails, because buffered content is disabled - despite it being enabled in the test.

and provide a Java sample that also shows just the problem.

What is wrong with the Kotlin example in the repository I linked in the issue? From my point of view, it clearly shows the problem. You would expect the test to pass, but it fails because the default bean with disabled buffered content is being taken. Have you had a look at the repository?

Comment From: snicoll

No. Quite the contrary, I invested quite some time to prepare the executable code POC for you with explanations.

Thanks. Unfortunately, it's far more easier for us to get to the point with a sample that showcases the actual problem rather than reading through your own analysis. We will have to go through the code ourselves anyways to make up our own mind and additional details are certainly welcome if that doesn't proved fruitful.

What is wrong with the Kotlin example in the repository I linked in the issue?

I think what Phil meant is that a minimal sample should be in Java ideally. Kotlin or additional libraries (like Lombok) has proven to be in the way of debugging and also can induce additional side effects. The goal here is to optimize our time as much as possible.

Comment From: Tooa

Thanks. Unfortunately, it's far more easier for us to get to the point with a sample that showcases the actual problem rather than reading through your own analysis

I see. Then ignore everything written in the README.md of the sample code and just execute one of the tests and see it fail. You would expect it to pass. That is the problem.

I think what Phil meant is that a minimal sample should be in Java ideally. Kotlin or additional libraries (like Lombok) has proven to be in the way of debugging and also can induce additional side effects. The goal here is to optimize our time as much as possible.

I am sure this is not a Kotlin specific issue. But I understand your point. I will provide a Java sample.

Edit: I also re-worked the original issue. Moving the most relevant information up-front and adding details that where only present in the sample code. Hope this helps!

Comment From: Tooa

Here is the Java sample code. This time no fluff. The tests in example1 and example2 show the same problem with two different ways to configure a custom bean for enabling buffered content. You would expect the tests to pass in both cases. But they fail.

Comment From: philwebb

No. Quite the contrary, I invested quite some time to prepare the executable code POC for you with explanations.

Sorry, no offense was meant by comment. We've seen quite an uptick in AI generated pull-requests and issues and I wanted to check that wasn't the case here.

Comment From: philwebb

I think as things stand, things are a little clunky and we should probably make the beans in MockRestServiceServerAutoConfiguration @ConditionalOnMissingBean.

In the meantime, you can do the following:

@RestClientTest(value = RestClientService.class, properties = { "my-client.base-url=http://localhost", })
@AutoConfigureMockRestServiceServer(enabled = false)
@Import({ContentInterceptor.class,BufferingRestClientAdditionalBeanTestConfiguration.class})
public class RestClientServiceTest {

    private final MockRestServiceServer mockRestServiceServer;

    private final RestClientService unitUnderTest;

    @Autowired
    public RestClientServiceTest(MockServerRestClientCustomizer restClientCustomizer, RestClientService unitUnderTest) {
        mockRestServiceServer = restClientCustomizer.getServer();
        this.unitUnderTest = unitUnderTest;
    }

    @Test
    void testInterceptRequest() {
        mockRestServiceServer.expect(requestTo("http://localhost/foo")).andExpect(method(HttpMethod.GET))
                .andRespond(withSuccess("hello world", MediaType.TEXT_PLAIN));
        var actual = unitUnderTest.retrieveFoo();
        assertEquals("hello world", actual);
    }
}
@TestConfiguration
public class BufferingRestClientAdditionalBeanTestConfiguration {

    @Bean
    public MockServerRestClientCustomizer myMockServerRestClientCustomizer() {
        var customizer = new MockServerRestClientCustomizer();
        customizer.setBufferContent(true);
        return customizer;
    }

}

This will disable our auto-configuration, allowing you to create the customizer you want.

Comment From: philwebb

Flagging for team attention to see if we consider this a bug of omission or an enhancement.