Spring Boot version: 3.2.3, 3.2.4 (Spring Core 6.1.4) Java version: Temurin 17 (17.0.10)
Reproduced on Windows and Github Actions with Ubuntu 22.04.
Minimal example: https://github.com/RHarryH/spring-webmvc-github-issue
Description:
I have observed weird issue of WebMvcTest
failure with code 405
instead of expected 200
because Spring does not resolve controller method based on the request url. 405
error happens when there are two endpoints with the same request url but different HTTP method. When request urls are different 404
error is thrown.
This happens only when specific hierarchy of controllers is used and when WebMvcTest
is run after SpringBootTest
(achieved by changing test class execution order in junit-platform.properties
.
The hierarchy of the controllers is as follows:
- Controller
interface defining endpoints and annotating them with @XMapping
annotations
- AbstractController
implementing delete
method. Please not it is a package-private abstract class
- ActualController
implementing remaining methods
The presence of AbstractController
is the main cause of the issue. Working workaround is making it public
.
When debugging tests SpringBootTest
logs contains:
2024-04-07T12:01:20.781+02:00 DEBUG 33568 --- [ main] _.s.web.servlet.HandlerMapping.Mappings :
c.a.i.ActualController:
{POST [/v1/a]}: add(Body,BindingResult)
{POST [/v1/a/{id}]}: update(UUID,Body,BindingResult)
{DELETE [/v1/a/{id}]}: delete(UUID)
while WebMvcTest
logs miss DELETE
method:
2024-04-07T12:01:22.203+02:00 DEBUG 33568 --- [ main] _.s.web.servlet.HandlerMapping.Mappings :
c.a.i.ActualController:
{POST [/v1/a]}: add(Body,BindingResult)
{POST [/v1/a/{id}]}: update(UUID,Body,BindingResult)
I have tracked down the rootcause to the org.springframework.core.MethodIntrocpector
class and selectMethods(Class<?> targetType, final MetadataLookup<T> metadataLookup)
method (https://github.com/spring-projects/spring-framework/blob/9bd6aef090161cb26ef5d1326d6604b0fa3d54ac/spring-core/src/main/java/org/springframework/core/MethodIntrospector.java#L75).
Line 74 correctly inspects the method. The problem is in line 77. When SpringBootTest
tests are run the fields looks like below:
method = {Method@7492} "public void com.avispa.issue.AbstractController.delete(java.util.UUID)"
specificMethod = {Method@7493} "public void com.avispa.issue.ActualController.delete(java.util.UUID)"
result = {RequestMappingInfo@7494} "{DELETE [/v1/a/{id}]}"
bridgedMethod = {Method@7492} "public void com.avispa.issue.AbstractController.delete(java.util.UUID)"
But then whenWebMvcTest
tests are run it looks like below:
method = {Method@9155} "public void com.avispa.issue.AbstractController.delete(java.util.UUID)"
specificMethod = {Method@9156} "public void com.avispa.issue.ActualController.delete(java.util.UUID)"
result = {RequestMappingInfo@9157} "{DELETE [/v1/a/{id}]}"
bridgedMethod = {Method@7492} "public void com.avispa.issue.AbstractController.delete(java.util.UUID)"
As you can see in second case method
and bridgedMethod
represents the same method but are in fact different instances of Method
class. And because the comparison in line 77 is done by reference, it failes and does not add found DELETE
method to the mappings registry.
When SpringBootTest
tests are disabled, the problem does not exist.
Comment From: wilkinsona
Thanks very much for the detailed report, analysis, and minimal example.
It turns out that this isn't a Spring Boot problem and that it can be reproduced with pure Spring Framework. For the problem to occur, two contexts must be involved. Using @WebMvcTest
and @SpringBootTest
achieves this, but the same can be achieved with any configuration where the tests' context configurations are different.
I've forked the example and pushed an update that removes Spring Boot. Its tests still fail in the same way and log similar output for the mappings:
11:15:06.563 [main] DEBUG _org.springframework.web.servlet.HandlerMapping.Mappings --
c.a.i.ActualController:
{POST [/v1/a]}: add(Body,BindingResult)
{POST [/v1/a/{id}]}: update(UUID,Body,BindingResult)
{DELETE [/v1/a/{id}]}: delete(UUID)
11:15:06.848 [main] DEBUG _org.springframework.web.servlet.HandlerMapping.Mappings --
c.a.i.ActualController:
{POST [/v1/a]}: add(Body,BindingResult)
{POST [/v1/a/{id}]}: update(UUID,Body,BindingResult)
My suspicion at the moment is that the problem's due to some static-scoped caching during the method introspection. We'll transfer this issue to the Framework team so that they can continue the investigation.
Comment From: wilkinsona
I should also have noted that I don't think this is even test-specific. I was also able to reproduce the problem (judging by the logging of the mappings) by adding a main method to the app in which I called SpringApplication.run(…).close()
twice.
Comment From: sbrannen
Thanks to the examples from @RHarryH and @wilkinsona, I've been able to reproduce this in a simplified, stand-alone test class.
@SpringJUnitWebConfig
class ReproTests {
@Nested
// Configure a test property source to ensure we get a different ApplicationContext
// than the one created for the enclosing ReproTests class.
@TestPropertySource(properties = "enigma = 42")
class ActualControllerTests {
private final MockMvc mockMvc;
private final UUID id = UUID.randomUUID();
ActualControllerTests(WebApplicationContext wac) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
}
@Test
void givenDto_whenUpdate_thenServiceCalled() throws Exception {
mockMvc.perform(post("/v1/a/" + id)
.contentType(APPLICATION_JSON)
.content("""
{"test" : "Value"}
"""))
.andExpect(status().isOk());
}
@Test
void whenDelete_thenServiceCalled() throws Exception {
mockMvc.perform(delete("/v1/a/" + id))
.andExpect(status().isOk());
}
}
@Configuration
@EnableWebMvc
@Import(ActualController.class)
static class Config {
}
static class Body {
private String test;
Body() {
}
Body(String test) {
this.test = test;
}
void setTest(String test) {
this.test = test;
}
String getTest() {
return test;
}
}
@RestController
interface Controller {
@PostMapping("/{id}")
void update(@PathVariable UUID id, @RequestBody Body body);
@DeleteMapping("/{id}")
void delete(@PathVariable UUID id);
}
// public
static abstract class AbstractController implements Controller {
@Override
public void delete(UUID id) {
}
}
@RequestMapping("/v1/a")
public static class ActualController extends AbstractController {
@Override
public void update(UUID id, Body body) {
}
}
}
Comment From: sbrannen
- Related to #30906
Comment From: RHarryH
Thanks guys for such quick reaction and further simplification of the reproduction code. Actually this can be simplified even more because Body
is not necessary to achieve the result. We don't even need update
method with it's test. It fails with 404
now but the root cause is still the same.
@SpringJUnitWebConfig
class ReproTests {
@Nested
// Configure a test property source to ensure we get a different ApplicationContext
// than the one created for the enclosing ReproTests class.
@TestPropertySource(properties = "enigma = 42")
class ActualControllerTests {
private final MockMvc mockMvc;
private final UUID id = UUID.randomUUID();
ActualControllerTests(WebApplicationContext wac) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
}
@Test
void whenDelete_thenServiceCalled() throws Exception {
mockMvc.perform(delete("/v1/a/" + id))
.andExpect(status().isOk());
}
}
@Configuration
@EnableWebMvc
@Import(ActualController.class)
static class Config {
}
@RestController
interface Controller {
@DeleteMapping("/{id}")
void delete(@PathVariable UUID id);
}
// public
static abstract class AbstractController implements Controller {
@Override
public void delete(UUID id) {
}
}
@RequestMapping("/v1/a")
public static class ActualController extends AbstractController {
}
}
Comment From: sbrannen
Hi @RHarryH,
Thanks again for reporting the issue. This has been addressed and included in today's releases for 6.1.6, 6.0.19, and 5.3.34.
Thanks guys for such quick reaction and further simplification of the reproduction code.
You're welcome.
Actually this can be simplified even more because
Body
is not necessary to achieve the result.
Indeed. Though, my original goal was to only simplify it enough for inclusion in a stand-alone test case, and I didn't bother simplifying that integration test further beyond that.
Rather, I later reduced it to the minimum in a unit test which you can see in the associated commit.
My suspicion at the moment is that the problem's due to some static-scoped caching during the method introspection.
Your hunch was right, @wilkinsona.
It turned out to be an issue involing two competing static caches that only reared its head after a context refresh, and the devil was in the details.
See e702733c7b787300d63c21bc473e1aab5c882991 for the full story.