Testing an Async Spring MVC Controller with MockMvc

Test asynchronous Spring MVC controllers with MockMvc and MockMvcTester. Run Spring Boot 4.1.2 examples for CompletableFuture, Callable, DeferredResult, and error responses.

Unit test spring boot async rest controller

To test an async REST controller with MockMvc, we perform the request, check that async processing has started, and then call asyncDispatch(mvcResult) to get the final response. The first perform() call returns as soon as the controller method returns its CompletableFuture, Callable or DeferredResult, so the response has no body yet. The second call, with asyncDispatch(), completes the request with the async result.

We need the two steps whenever a Spring MVC controller returns its result later from another thread, for example a report that takes seconds to build or a call to a slow remote service.

The following example tests a controller method that returns a CompletableFuture of a monthly report. The comments show what each line checks.

MvcResult mvcResult = mockMvc.perform(get("/reports/2026-09"))
    .andExpect(request().asyncStarted())                       // the method returned a CompletableFuture
    .andReturn();                                              // status 200, body still empty

mockMvc.perform(asyncDispatch(mvcResult))                      // waits for the result and completes the request
    .andExpect(status().isOk())                                // 200
    .andExpect(content().json("{\"month\":\"2026-09\",\"orders\":95}"));

Notice that the body assertion goes after asyncDispatch(). On the first response, the same assertion fails, because the body is empty.

1. How an Async Request Works in Spring MVC

A controller method can return a value that Spring MVC completes later, such as a CompletableFuture. Spring MVC then starts Servlet async processing. The request thread goes back to the server’s thread pool, and the response stays open until the result is ready. When the result arrives, Spring MVC performs an async dispatch, which runs the request through the DispatcherServlet a second time and writes the result to the response.

For example, an online shop has an admin page with a monthly sales report. The report query takes several seconds. With a CompletableFuture, the Tomcat thread is free to serve other shoppers while the database works.

Spring MVC supports these async return types.

Return type Who produces the result
CompletableFuture (or CompletionStage) Our code, on an executor that we choose
Callable Spring MVC runs the Callable on its task executor
DeferredResult Any thread that calls setResult() or setErrorResult()
ResponseBodyEmitter, SseEmitter Our code sends several objects or events over time

In a test, MockMvc has no running server, so nobody performs the async dispatch for us. The diagram shows the two perform() calls of a test and what the test sees after each one.

MockMvc starts an async report request, then dispatches the completed result to obtain the final JSON response.

The first perform() only starts the request. The asyncDispatch() call returns the response with the body.

2. The Async Controller Example

The following example is a reports API in a Spring Boot 4.1.2 app on Java 17 or later, tested with JUnit 6 and AssertJ. A Report record holds a month and the number of orders. The ReportService builds a report on a background thread after a 200 ms delay, like a slow database query, and throws ReportNotFoundException for an unknown month. The complete project is linked at the end of this article.

@Service
public class ReportService {

  private static final Map<String, Integer> ORDERS = Map.of("2026-08", 120, "2026-09", 95);

  private final Executor slowExecutor;

  public ReportService(TaskExecutor taskExecutor) {
    // Runs each task after 200 ms, so the report takes some time like a real database query
    this.slowExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS, taskExecutor);
  }

  public CompletableFuture<Report> monthlyReport(String month) {
    return CompletableFuture.supplyAsync(() -> {
      Integer orders = ORDERS.get(month);
      if (orders == null) {
        throw new ReportNotFoundException(month);
      }
      return new Report(month, orders);
    }, slowExecutor);
  }
}

The ReportController has one method for each async return type. The @ExceptionHandler method turns ReportNotFoundException into a 404 response with a ProblemDetail body.

@RestController
@RequestMapping("/reports")
public class ReportController {

  private final ReportService reportService;

  public ReportController(ReportService reportService) {
    this.reportService = reportService;
  }

  // 1. CompletableFuture: the service runs the work on its own executor
  @GetMapping("/{month}")
  public CompletableFuture<Report> monthly(@PathVariable String month) {
    return reportService.monthlyReport(month);
  }

  // 2. Callable: Spring MVC runs the Callable on its task executor
  @GetMapping("/status")
  public Callable<String> status() {
    return () -> "Reports are up to date";
  }

  // 3. DeferredResult: another thread sets the result, or the timeout value is used
  @GetMapping("/{month}/deferred")
  public DeferredResult<Report> deferred(@PathVariable String month) {
    DeferredResult<Report> result = new DeferredResult<>(5_000L);
    reportService.monthlyReport(month).whenComplete((report, error) -> {
      if (error != null) {
        result.setErrorResult(error.getCause());
      } else {
        result.setResult(report);
      }
    });
    return result;
  }

  @ExceptionHandler(ReportNotFoundException.class)
  public ProblemDetail notFound(ReportNotFoundException e) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
  }
}

The app returns these responses when it runs.

$ curl http://localhost:8080/reports/2026-09
{"month":"2026-09","orders":95}

$ curl http://localhost:8080/reports/status
Reports are up to date

$ curl http://localhost:8080/reports/2026-01
{"detail":"No report for 2026-01","instance":"/reports/2026-01","status":404,"title":"Not Found"}

3. Testing the Async Controller With asyncDispatch()

We test the controller with a @WebMvcTest slice test. The @WebMvcTest annotation starts only the web layer, such as the given controller, the MVC infrastructure and a MockMvc instance, so the test starts faster than a full @SpringBootTest. The ReportService is not part of the web layer, so we add it with @Import.

In Spring Boot 4, @WebMvcTest is in the package org.springframework.boot.webmvc.test.autoconfigure and needs the spring-boot-starter-webmvc-test dependency. Older tutorials use @RunWith(SpringRunner.class) from JUnit 4, which we don’t need with JUnit 5 or 6.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc-test</artifactId>
  <scope>test</scope>
</dependency>

3.1. Testing a CompletableFuture Method

The test performs the request and checks request().asyncStarted(), which confirms that the controller method returned an async value. The request().asyncResult() matcher compares the value that the CompletableFuture produced, here a Report. After that, asyncDispatch(mvcResult) completes the request, and we check the status and the JSON body like in any other MockMvc test.

@WebMvcTest(ReportController.class)
@Import(ReportService.class)
class ReportControllerTest {

  @Autowired
  MockMvc mockMvc;

  @Test
  void completableFutureReturnsReport() throws Exception {
    MvcResult mvcResult = mockMvc.perform(get("/reports/2026-09"))
        .andExpect(request().asyncStarted())
        .andExpect(request().asyncResult(new Report("2026-09", 95)))
        .andReturn();

    mockMvc.perform(asyncDispatch(mvcResult))
        .andExpect(status().isOk())
        .andExpect(content().contentType("application/json"))
        .andExpect(content().json("{\"month\":\"2026-09\",\"orders\":95}"));
  }
}

The report takes about 200 ms, and the test still passes without a separate sleep in the test. The asyncResult() matcher and the asyncDispatch() call wait for the async result, up to the async request timeout, as the Javadoc of MvcResult.getAsyncResult() describes.

3.2. Testing Callable and DeferredResult Methods

The test steps are the same for every async return type. Only the request and the expected response change.

@Test
void callableReturnsText() throws Exception {
  MvcResult mvcResult = mockMvc.perform(get("/reports/status"))
      .andExpect(request().asyncStarted())
      .andReturn();

  mockMvc.perform(asyncDispatch(mvcResult))
      .andExpect(status().isOk())
      .andExpect(content().string("Reports are up to date"));
}

@Test
void deferredResultReturnsReport() throws Exception {
  MvcResult mvcResult = mockMvc.perform(get("/reports/2026-08/deferred"))
      .andExpect(request().asyncStarted())
      .andReturn();

  mockMvc.perform(asyncDispatch(mvcResult))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.orders").value(120));
}

3.3. Testing an Exception From the Async Task

When the async task fails, Spring MVC sends the exception through the async dispatch to the usual exception handling, so our @ExceptionHandler method runs. A failed CompletableFuture wraps the exception in a CompletionException, and Spring MVC unwraps it, so the handler for ReportNotFoundException matches.

@Test
void exceptionInAsyncTaskGoesToExceptionHandler() throws Exception {
  MvcResult mvcResult = mockMvc.perform(get("/reports/2026-01"))
      .andExpect(request().asyncStarted())
      .andReturn();

  mockMvc.perform(asyncDispatch(mvcResult))
      .andExpect(status().isNotFound())
      .andExpect(content().contentType("application/problem+json"))
      .andExpect(jsonPath("$.detail").value("No report for 2026-01"));
}

The error status code is visible only after asyncDispatch(). A test that expects 404 on the first response fails with “Status expected:<404> but was:<200>”, because the first response always has status 200 while the request is still running.

4. Testing Async Requests With MockMvcTester

Spring Framework 6.2 added MockMvcTester, a MockMvc API with AssertJ assertions. Its exchange() method waits until an async request is complete, so we don’t call asyncDispatch() at all. By default, exchange() waits up to the async request timeout, which is 10 seconds in MockMvc unless the app sets spring.mvc.async.request-timeout. The exchange(Duration) method sets another limit for one request.

Spring Boot 4 creates a MockMvcTester bean in a @WebMvcTest test when AssertJ is on the classpath, which spring-boot-starter-webmvc-test brings. So we inject it like MockMvc.

@WebMvcTest(ReportController.class)
@Import(ReportService.class)
class ReportControllerTesterTest {

  @Autowired
  MockMvcTester mvc;

  @Test
  void exchangeWaitsForTheAsyncResult() {
    assertThat(mvc.get().uri("/reports/2026-09"))
        .hasStatusOk()
        .bodyJson().isLenientlyEqualTo("{\"month\":\"2026-09\",\"orders\":95}");
  }

  @Test
  void exchangeWithTimeout() {
    assertThat(mvc.get().uri("/reports/2026-08/deferred").exchange(Duration.ofSeconds(2)))
        .hasStatusOk()
        .bodyJson().extractingPath("$.orders").isEqualTo(120);
  }

  @Test
  void asyncErrorIsHandled() {
    assertThat(mvc.get().uri("/reports/2026-01"))
        .hasStatus(HttpStatus.NOT_FOUND)
        .bodyJson().extractingPath("$.detail").isEqualTo("No report for 2026-01");
  }
}

In the first and the third test, we pass the request builder to assertThat(), and MockMvcTester calls exchange() for us. The two styles test the same behavior, and the choice depends on the project.

MockMvc with asyncDispatch() MockMvcTester
Async request two calls, perform() and perform(asyncDispatch(…)) one call, exchange() waits
Assertions Hamcrest-style andExpect() matchers AssertJ assertThat()
Check the first response before the dispatch request().asyncResult(…) asyncExchange() returns the first response without waiting
Spring Framework version every supported version 6.2 or later

For new tests, MockMvcTester is shorter and has no missing-dispatch mistake. In a project with many existing MockMvc tests, asyncDispatch() keeps the async tests in the same style as the rest.

5. Configuring the Async Executor and Timeout

Spring Boot uses an AsyncTaskExecutor for Spring MVC asynchronous requests. Without a custom executor, Boot supplies an applicationTaskExecutor. The following property sets the default timeout for an asynchronous request. A timeout can result in an error response, but the exact status depends on the configured timeout handling, so test that behavior in your application.

spring.mvc.async.request-timeout=30s

A DeferredResult with its own timeout, such as new DeferredResult<>(5_000L), overrides the global value for that request. To use our own thread pool, a WebMvcConfigurer bean overrides configureAsyncSupport() and sets the executor and the timeout in code.

@Configuration
public class AsyncConfig implements WebMvcConfigurer {

  @Bean
  public ThreadPoolTaskExecutor mvcTaskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setThreadNamePrefix("mvc-task-");
    executor.setCorePoolSize(4);
    return executor;
  }

  @Override
  public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
    configurer.setTaskExecutor(mvcTaskExecutor());
    configurer.setDefaultTimeout(30_000);
  }
}

With AsyncConfig, the Callable runs on threads named mvc-task-1, mvc-task-2 and so on. The MVC test slice picks up this WebMvcConfigurer, so the tests in section 3 still exercise the configured MVC behavior. The test response assertions do not establish which thread executed a Callable; inspect the thread or add an explicit executor assertion when that matters.

Without our own executor bean and with virtual threads enabled (spring.threads.virtual.enabled=true), Spring Boot can create an applicationTaskExecutor that uses virtual threads, and the tests stay the same.

6. Async Controller Testing FAQs

6.1. Why Is the Response Body Empty in My MockMvc Test?

The test checks the first response and never calls asyncDispatch(). The first response has status 200 and an empty body, so JSON assertions fail with messages like these.

java.lang.AssertionError: No value at JSON path "$.orders"
java.lang.IllegalStateException: org.json.JSONException: Unparsable JSON string:
java.lang.AssertionError: Status expected:<404> but was:<200>

We add asyncDispatch(mvcResult) as in section 3.1, or switch the test to MockMvcTester as in section 4.

6.2. Do I Need asyncDispatch() for a Method Annotated With @Async?

No. When a service method has @Async and the controller waits for it with join(), the controller method returns a plain value, and MockMvc sees a normal synchronous request. We need asyncDispatch() only when the controller method itself returns CompletableFuture, Callable, DeferredResult or another async type.

6.3. Does asyncDispatch() Work With @SpringBootTest?

Yes. With @SpringBootTest and @AutoConfigureMockMvc, the injected MockMvc handles async requests in the same way, so the test code from section 3 works unchanged. The @SpringBootTest annotation starts the whole application context, so the test is slower than the @WebMvcTest version.

7. Conclusion

An async controller method returns a CompletableFuture, Callable or DeferredResult, and Spring MVC completes the request later with an async dispatch. MockMvc has no server that does the dispatch, so a MockMvc test performs the request, checks asyncStarted(), and calls asyncDispatch(mvcResult) before it checks the status and the body.

Exceptions from the async task reach the @ExceptionHandler methods, but the error status shows up only in the dispatched response. With MockMvcTester, the exchange() method waits for the async result, so one call returns the final response.

Happy Learning!

Source code on GitHub

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.