Add explicit support for asynchronous requests in MockMvcTester

This commit makes asynchronous requests first class by providing a
MvcTestResult that represents the final, completed, state of a request
by default. Previously, the result was an intermediate step that may
require an ASYNC dispatch to be fully usable. Now it is de facto
immutable.

To make things a bit more explicit, an `.exchange(Duration)` method has
been added to provide a dedicated time to wait. The default applies a
default timeout that is consistent with what MVcResult#getAsyncResult
does.

Given that we apply the ASYNC dispatch automatically, the intermediate
response is no longer available by default. As a result, the asyncResult
is not available for assertions.

As always, it is possible to use plain MockMvc by using the `perform`
method that takes the regular RequestBuilder as an input. When this
method is invoked, no asynchronous handling is done.

Closes gh-33040
This commit is contained in:
Stéphane Nicoll
2024-06-17 07:55:53 +02:00
parent a1b0099795
commit a7503e7200
5 changed files with 359 additions and 134 deletions

View File

@@ -20,6 +20,7 @@ import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -36,6 +37,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.Part;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
@@ -53,6 +55,7 @@ import org.springframework.stereotype.Controller;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.Person;
import org.springframework.test.web.servlet.ResultMatcher;
import org.springframework.test.web.servlet.assertj.MockMvcTester.MockMvcRequestBuilder;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.GetMapping;
@@ -73,14 +76,16 @@ import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import static java.util.Map.entry;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.InstanceOfAssertFactories.map;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* Integration tests for {@link MockMvcTester}.
@@ -97,12 +102,60 @@ public class MockMvcTesterIntegrationTests {
this.mvc = MockMvcTester.from(wac);
}
@Nested
class PerformTests {
@Test
void syncRequestWithDefaultExchange() {
assertThat(mvc.get().uri("/greet")).hasStatusOk();
}
@Test
void asyncRequestWithDefaultExchange() {
assertThat(mvc.get().uri("/streaming").param("timeToWait", "100")).hasStatusOk()
.hasBodyTextEqualTo("name=Joe&someBoolean=true");
}
@Test
void syncRequestWithExplicitExchange() {
assertThat(mvc.get().uri("/greet").exchange()).hasStatusOk();
}
@Test
void asyncRequestWithExplicitExchange() {
assertThat(mvc.get().uri("/streaming").param("timeToWait", "100").exchange())
.hasStatusOk().hasBodyTextEqualTo("name=Joe&someBoolean=true");
}
@Test
void syncRequestWithExplicitExchangeIgnoresDuration() {
Duration timeToWait = mock(Duration.class);
assertThat(mvc.get().uri("/greet").exchange(timeToWait)).hasStatusOk();
verifyNoInteractions(timeToWait);
}
@Test
void asyncRequestWithExplicitExchangeAndEnoughTimeToWait() {
assertThat(mvc.get().uri("/streaming").param("timeToWait", "100").exchange(Duration.ofMillis(200)))
.hasStatusOk().hasBodyTextEqualTo("name=Joe&someBoolean=true");
}
@Test
void asyncRequestWithExplicitExchangeAndNotEnoughTimeToWait() {
MockMvcRequestBuilder builder = mvc.get().uri("/streaming").param("timeToWait", "500");
assertThatIllegalStateException()
.isThrownBy(() -> builder.exchange(Duration.ofMillis(100)))
.withMessageContaining("was not set during the specified timeToWait=100");
}
}
@Nested
class RequestTests {
@Test
void hasAsyncStartedTrue() {
assertThat(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON))
// Need #perform as the regular exchange waits for async completion automatically
assertThat(mvc.perform(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON)))
.request().hasAsyncStarted(true);
}
@@ -272,8 +325,10 @@ public class MockMvcTesterIntegrationTests {
@Test
void asyncResult() {
assertThat(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON))
.asyncResult().asInstanceOf(map(String.class, Object.class))
// Need #perform as the regular exchange waits for async completion automatically
MvcTestResult result = mvc.perform(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON));
assertThat(result.getMvcResult().getAsyncResult())
.asInstanceOf(InstanceOfAssertFactories.map(String.class, Object.class))
.containsOnly(entry("key", "value"));
}
@@ -441,12 +496,6 @@ public class MockMvcTesterIntegrationTests {
result -> assertThat(result).apply(mvcResult -> {}));
}
@Test
void assertAsyncResultWithUnresolvedException() {
testAssertionFailureWithUnresolvableException(
result -> assertThat(result).asyncResult());
}
@Test
void assertContentTypeWithUnresolvedException() {
testAssertionFailureWithUnresolvableException(
@@ -617,6 +666,21 @@ public class MockMvcTesterIntegrationTests {
public Callable<Map<String, String>> getCallable() {
return () -> Collections.singletonMap("key", "value");
}
@GetMapping("/streaming")
StreamingResponseBody streaming(@RequestParam long timeToWait) {
return out -> {
PrintStream stream = new PrintStream(out, true, StandardCharsets.UTF_8);
stream.print("name=Joe");
try {
Thread.sleep(timeToWait);
stream.print("&someBoolean=true");
}
catch (InterruptedException e) {
/* no-op */
}
};
}
}
@Controller

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import java.time.Duration;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
@@ -31,6 +32,9 @@ import org.springframework.http.ResponseEntity;
import org.springframework.test.web.Person;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
@@ -58,132 +62,222 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
*/
class AsyncTests {
private final AsyncController asyncController = new AsyncController();
@Nested
class MockMvcTests {
private final MockMvc mockMvc = standaloneSetup(this.asyncController).build();
private final MockMvc mockMvc = standaloneSetup(new AsyncController()).build();
@Test
void callable() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("callable", "true"))
.andExpect(request().asyncStarted())
.andExpect(request().asyncResult(equalTo(new Person("Joe"))))
.andExpect(request().asyncResult(new Person("Joe")))
.andReturn();
@Test
void callable() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("callable", "true"))
.andExpect(request().asyncStarted())
.andExpect(request().asyncResult(equalTo(new Person("Joe"))))
.andExpect(request().asyncResult(new Person("Joe")))
.andReturn();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
@Test
void streaming() throws Exception {
this.mockMvc.perform(get("/1").param("streaming", "true"))
.andExpect(request().asyncStarted())
.andDo(MvcResult::getAsyncResult) // fetch async result similar to "asyncDispatch" builder
.andExpect(status().isOk())
.andExpect(content().string("name=Joe"));
}
@Test
void streamingSlow() throws Exception {
this.mockMvc.perform(get("/1").param("streamingSlow", "true"))
.andExpect(request().asyncStarted())
.andDo(MvcResult::getAsyncResult)
.andExpect(status().isOk())
.andExpect(content().string("name=Joe&someBoolean=true"));
}
@Test
void streamingJson() throws Exception {
this.mockMvc.perform(get("/1").param("streamingJson", "true"))
.andExpect(request().asyncStarted())
.andDo(MvcResult::getAsyncResult)
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.5}"));
}
@Test
void deferredResult() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResult", "true"))
.andExpect(request().asyncStarted())
.andReturn();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
@Test
void deferredResultWithImmediateValue() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResultWithImmediateValue", "true"))
.andExpect(request().asyncStarted())
.andExpect(request().asyncResult(new Person("Joe")))
.andReturn();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
@Test // SPR-13079
void deferredResultWithDelayedError() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResultWithDelayedError", "true"))
.andExpect(request().asyncStarted())
.andReturn();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().is5xxServerError())
.andExpect(content().string("Delayed Error"));
}
@Test
void listenableFuture() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("listenableFuture", "true"))
.andExpect(request().asyncStarted())
.andReturn();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
@Test // SPR-12597
void completableFutureWithImmediateValue() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("completableFutureWithImmediateValue", "true"))
.andExpect(request().asyncStarted())
.andReturn();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
@Test // SPR-12735
void printAsyncResult() throws Exception {
StringWriter writer = new StringWriter();
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResult", "true"))
.andDo(print(writer))
.andExpect(request().asyncStarted())
.andReturn();
assertThat(writer.toString()).contains("Async started = true");
writer = new StringWriter();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andDo(print(writer))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
assertThat(writer.toString()).contains("Async started = false");
}
}
@Test
void streaming() throws Exception {
this.mockMvc.perform(get("/1").param("streaming", "true"))
.andExpect(request().asyncStarted())
.andDo(MvcResult::getAsyncResult) // fetch async result similar to "asyncDispatch" builder
.andExpect(status().isOk())
.andExpect(content().string("name=Joe"));
}
@Nested
class MockMvcTesterTests {
@Test
void streamingSlow() throws Exception {
this.mockMvc.perform(get("/1").param("streamingSlow", "true"))
.andExpect(request().asyncStarted())
.andDo(MvcResult::getAsyncResult)
.andExpect(status().isOk())
.andExpect(content().string("name=Joe&someBoolean=true"));
}
private final MockMvcTester mockMvc = MockMvcTester.of(new AsyncController());
@Test
void streamingJson() throws Exception {
this.mockMvc.perform(get("/1").param("streamingJson", "true"))
.andExpect(request().asyncStarted())
.andDo(MvcResult::getAsyncResult)
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.5}"));
}
@Test
void callable() {
assertThat(mockMvc.get().uri("/1").param("callable", "true"))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
}
@Test
void deferredResult() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResult", "true"))
.andExpect(request().asyncStarted())
.andReturn();
@Test
void streaming() {
assertThat(this.mockMvc.get().uri("/1").param("streaming", "true"))
.hasStatusOk().hasBodyTextEqualTo("name=Joe");
}
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
@Test
void streamingSlow() {
assertThat(this.mockMvc.get().uri("/1").param("streamingSlow", "true"))
.hasStatusOk().hasBodyTextEqualTo("name=Joe&someBoolean=true");
}
@Test
void deferredResultWithImmediateValue() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResultWithImmediateValue", "true"))
.andExpect(request().asyncStarted())
.andExpect(request().asyncResult(new Person("Joe")))
.andReturn();
@Test
void streamingJson() {
assertThat(this.mockMvc.get().uri("/1").param("streamingJson", "true"))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.5}");
}
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
@Test
void deferredResult() {
assertThat(this.mockMvc.get().uri("/1").param("deferredResult", "true"))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
}
@Test // SPR-13079
void deferredResultWithDelayedError() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResultWithDelayedError", "true"))
.andExpect(request().asyncStarted())
.andReturn();
@Test
void deferredResultWithImmediateValue() {
assertThat(this.mockMvc.get().uri("/1").param("deferredResultWithImmediateValue", "true"))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
}
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().is5xxServerError())
.andExpect(content().string("Delayed Error"));
}
@Test // SPR-13079
void deferredResultWithDelayedError() {
assertThat(this.mockMvc.get().uri("/1").param("deferredResultWithDelayedError", "true"))
.hasStatus5xxServerError().hasBodyTextEqualTo("Delayed Error");
}
@Test
void listenableFuture() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("listenableFuture", "true"))
.andExpect(request().asyncStarted())
.andReturn();
@Test
void listenableFuture() {
assertThat(this.mockMvc.get().uri("/1").param("listenableFuture", "true"))
.hasStatusOk().hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
}
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
@Test // SPR-12597
void completableFutureWithImmediateValue() {
assertThat(this.mockMvc.get().uri("/1").param("completableFutureWithImmediateValue", "true"))
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
}
@Test // SPR-12597
void completableFutureWithImmediateValue() throws Exception {
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("completableFutureWithImmediateValue", "true"))
.andExpect(request().asyncStarted())
.andReturn();
@Test // SPR-12735
void printAsyncResult() {
StringWriter asyncWriter = new StringWriter();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
}
@Test // SPR-12735
void printAsyncResult() throws Exception {
StringWriter writer = new StringWriter();
MvcResult mvcResult = this.mockMvc.perform(get("/1").param("deferredResult", "true"))
.andDo(print(writer))
.andExpect(request().asyncStarted())
.andReturn();
assertThat(writer.toString()).contains("Async started = true");
writer = new StringWriter();
this.mockMvc.perform(asyncDispatch(mvcResult))
.andDo(print(writer))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"));
assertThat(writer.toString()).contains("Async started = false");
// Use #perform to not complete asynchronous request automatically
RequestBuilder requestBuilder = this.mockMvc.get().uri("/1").param("deferredResult", "true");
MvcTestResult result = this.mockMvc.perform(requestBuilder);
assertThat(result).debug(asyncWriter).request().hasAsyncStarted(true);
assertThat(asyncWriter.toString()).contains("Async started = true");
asyncWriter = new StringWriter(); // Reset
assertThat(this.mockMvc.perform(asyncDispatch(result.getMvcResult())))
.debug(asyncWriter)
.hasStatusOk()
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.hasBodyTextEqualTo("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
assertThat(asyncWriter.toString()).contains("Async started = false");
}
}