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.