Test Spring Boot Async REST Controller with MockMvc

To test an async Spring MVC controller with MockMvc, we perform the request, check asyncStarted() and call asyncDispatch() to get the final response. MockMvcTester does both steps in one exchange() call.

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.

Next, we look at how async requests work in Spring MVC and build a controller with the three async return types. After that, we test each one with asyncDispatch(), test errors from the async task, and replace the two steps with one call to MockMvcTester.

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 typeWho produces the result
CompletableFuture (or CompletionStage)Our code, on an executor that we choose
CallableSpring MVC runs the Callable on its task executor
DeferredResultAny thread that calls setResult() or setErrorResult()
ResponseBodyEmitter, SseEmitterOur 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.

Sequence diagram with three lanes, Test (MockMvc), DispatcherServlet and Task executor. 1. The test calls perform(get("/reports/2026-09")). 2. The controller returns a CompletableFuture and the work starts on the task executor. 3. The test gets asyncStarted() true, status 200 and an empty body. 4. The report is ready and the async result is stored. 5. The test calls perform(asyncDispatch(mvcResult)). 6. The final response has status 200 and the body {"month":"2026-09","orders":95}. A note says that without step 5 the test only sees the empty first 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.1 app (Spring Framework 7.0.9) on Java 25, 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 in the Spring-Boot-Testing repository on GitHub.

@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 200 ms, and the test still passes without any waiting code of our own. 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 requesttwo calls, perform() and perform(asyncDispatch(…))one call, exchange() waits
AssertionsHamcrest-style andExpect() matchersAssertJ assertThat()
Check the first response before the dispatchrequest().asyncResult(…)asyncExchange() returns the first response without waiting
Spring Framework versionevery supported version6.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

By default, Spring Boot runs Callable results on its applicationTaskExecutor bean, whose threads are named task-1, task-2 and so on. An async request that has no result after the timeout ends with 503 Service Unavailable. We set the timeout in application.properties.

# An async request that takes longer than 30 seconds ends with 503 Service Unavailable
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 @WebMvcTest slice includes WebMvcConfigurer beans, so the tests from section 3 use the same executor and pass unchanged.

Without our own executor bean and with virtual threads enabled (spring.threads.virtual.enabled=true), Spring Boot creates the applicationTaskExecutor with 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.

8. References

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.