Affects: Spring Boot 2.5.4 / Spring Framework 5.3.9
I recently ran into an issue testing an endpoint of an application using CompletableFutures as a return type. I reproduced it using a minimal application based on the demo project from the Spring Boot Initializer which consists of these two classes:
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@RestController
public class TestController {
@GetMapping("/test")
CompletableFuture<String> test() {
return CompletableFuture.failedFuture(new RuntimeException("Error"));
}
}
If I start the application and make a GET call to the endpoint, I receive an internal server error as I would expect:
$ curl -i http://localhost:8080/test
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Wed, 15 Sep 2021 07:15:37 GMT
Connection: close
{"timestamp":"2021-09-15T07:15:37.497+00:00","status":500,"error":"Internal Server Error","path":"/test"}
Then I wrote a simple WebMvcTest to verify the behaviour:
@AutoConfigureMockMvc
@WebMvcTest(controllers = TestController.class)
class TestControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void test() throws Exception {
mockMvc
.perform(MockMvcRequestBuilders.get("/test"))
.andDo(MockMvcResultHandlers.print())
.andExpect(status().isInternalServerError());
}
}
I would expect the test to pass since the exception thrown in the CompletableFuture should lead to an internal server error there as well, however the result is:
MockHttpServletRequest:
HTTP Method = GET
Request URI = /test
Parameters = {}
Headers = []
Body = null
Session Attrs = {}
Handler:
Type = com.example.demo.TestController
Method = com.example.demo.TestController#test()
Async:
Async started = true
Async result = java.lang.RuntimeException: Error
Resolved Exception:
Type = null
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 200
Error message = null
Headers = []
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
MockHttpServletRequest:
HTTP Method = GET
Request URI = /test
Parameters = {}
Headers = []
Body = null
Session Attrs = {}
Handler:
Type = com.example.demo.TestController
Method = com.example.demo.TestController#test()
Async:
Async started = true
Async result = java.lang.RuntimeException: Error
Resolved Exception:
Type = null
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 200
Error message = null
Headers = []
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
java.lang.AssertionError: Status expected:<500> but was:<200>
Expected :500
Actual :200
So the http response is OK thought the controller returns a failed CompletableFuture.
Comment From: sbrannen
When testing asynchronous endpoints like that, you need to write your tests similar to the following.
@Test
void test() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/test"))
.andDo(print())
.andExpect(request().asyncStarted())
.andReturn();
assertThatExceptionOfType(NestedServletException.class)
.isThrownBy(() -> this.mockMvc.perform(asyncDispatch(mvcResult)))
.havingCause()
.isInstanceOf(RuntimeException.class)
.withMessage("Error");
}
The above test passes and verifies the exception thrown from your CompletableFuture
.
@rstoyanchev, do you have any further guidance here?
Comment From: ghost
That works however that's no what I'm trying to achieve with the test. I would like to test that the exception is correctly handled by the application and returns the correct http status code and error message (possibly generated by an error handler with ControllerAdvice
later) to the client. Is that just impossible with asynchronous endpoints and MockMvc right now?
Comment From: rstoyanchev
@hoshpak-next, the mockMvc.perform(asyncDispatch(mvcResult))
should cause exception resolution to take place, so if you have some @ExceptionHandler
, it should get invoked. If you don't, you will not get a 500 error but rather the unhandled exception, because there is no running server and no Servlet container, which is what turns unhandled exceptions into a 500 status.
As an alternative, you can write such tests as integration tests (with a running sever) via WebTestClient
which as of 5.3 also supports using MockMvc as the server. So you can write both integration and MockMvc tests using the same API.
Comment From: rstoyanchev
Closing, as this is expected behavior.