Skip to content

Commit a7503e7

Browse files
committed
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
1 parent a1b0099 commit a7503e7

File tree

5 files changed

+355
-130
lines changed

5 files changed

+355
-130
lines changed

spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MockMvcTester.java

+78-2
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
package org.springframework.test.web.servlet.assertj;
1818

1919
import java.net.URI;
20+
import java.time.Duration;
2021
import java.util.Arrays;
2122
import java.util.Collection;
2223
import java.util.Map;
2324
import java.util.function.Function;
2425
import java.util.stream.StreamSupport;
2526

27+
import jakarta.servlet.DispatcherType;
2628
import org.assertj.core.api.AssertProvider;
2729

2830
import org.springframework.http.HttpMethod;
@@ -384,6 +386,34 @@ private GenericHttpMessageConverter<Object> findJsonMessageConverter(
384386
.findFirst().orElse(null);
385387
}
386388

389+
/**
390+
* Execute the request using the specified {@link RequestBuilder}. If the
391+
* request is processing asynchronously, wait at most the given
392+
* {@code timeToWait} duration. If not specified, then fall back on the
393+
* timeout value associated with the async request, see
394+
* {@link org.springframework.mock.web.MockAsyncContext#setTimeout}.
395+
*/
396+
MvcTestResult exchange(RequestBuilder requestBuilder, @Nullable Duration timeToWait) {
397+
MvcTestResult result = perform(requestBuilder);
398+
if (result.getUnresolvedException() == null) {
399+
if (result.getRequest().isAsyncStarted()) {
400+
// Wait for async result before dispatching
401+
long waitMs = (timeToWait != null ? timeToWait.toMillis() : -1);
402+
result.getMvcResult().getAsyncResult(waitMs);
403+
404+
// Perform ASYNC dispatch
405+
RequestBuilder dispatchRequest = servletContext -> {
406+
MockHttpServletRequest request = result.getMvcResult().getRequest();
407+
request.setDispatcherType(DispatcherType.ASYNC);
408+
request.setAsyncStarted(false);
409+
return request;
410+
};
411+
return perform(dispatchRequest);
412+
}
413+
}
414+
return result;
415+
}
416+
387417

388418
/**
389419
* A builder for {@link MockHttpServletRequest} that supports AssertJ.
@@ -407,8 +437,31 @@ public MockMultipartMvcRequestBuilder multipart() {
407437
return new MockMultipartMvcRequestBuilder(this);
408438
}
409439

440+
/**
441+
* Execute the request. If the request is processing asynchronously,
442+
* wait at most the given timeout value associated with the async request,
443+
* see {@link org.springframework.mock.web.MockAsyncContext#setTimeout}.
444+
* <p>For simple assertions, you can wrap this builder in
445+
* {@code assertThat} rather than calling this method explicitly:
446+
* <pre><code class="java">
447+
* // These two examples are equivalent
448+
* assertThat(mvc.get().uri("/greet")).hasStatusOk();
449+
* assertThat(mvc.get().uri("/greet").exchange()).hasStatusOk();
450+
* </code></pre>
451+
* @see #exchange(Duration) to customize the timeout for async requests
452+
*/
410453
public MvcTestResult exchange() {
411-
return perform(this);
454+
return MockMvcTester.this.exchange(this, null);
455+
}
456+
457+
/**
458+
* Execute the request and wait at most the given {@code timeToWait}
459+
* duration for the asynchronous request to complete. If the request
460+
* is not asynchronous, the {@code timeToWait} is ignored.
461+
* @see #exchange()
462+
*/
463+
public MvcTestResult exchange(Duration timeToWait) {
464+
return MockMvcTester.this.exchange(this, timeToWait);
412465
}
413466

414467
@Override
@@ -429,8 +482,31 @@ private MockMultipartMvcRequestBuilder(MockMvcRequestBuilder currentBuilder) {
429482
merge(currentBuilder);
430483
}
431484

485+
/**
486+
* Execute the request. If the request is processing asynchronously,
487+
* wait at most the given timeout value associated with the async request,
488+
* see {@link org.springframework.mock.web.MockAsyncContext#setTimeout}.
489+
* <p>For simple assertions, you can wrap this builder in
490+
* {@code assertThat} rather than calling this method explicitly:
491+
* <pre><code class="java">
492+
* // These two examples are equivalent
493+
* assertThat(mvc.get().uri("/greet")).hasStatusOk();
494+
* assertThat(mvc.get().uri("/greet").exchange()).hasStatusOk();
495+
* </code></pre>
496+
* @see #exchange(Duration) to customize the timeout for async requests
497+
*/
432498
public MvcTestResult exchange() {
433-
return perform(this);
499+
return MockMvcTester.this.exchange(this, null);
500+
}
501+
502+
/**
503+
* Execute the request and wait at most the given {@code timeToWait}
504+
* duration for the asynchronous request to complete. If the request
505+
* is not asynchronous, the {@code timeToWait} is ignored.
506+
* @see #exchange()
507+
*/
508+
public MvcTestResult exchange(Duration timeToWait) {
509+
return MockMvcTester.this.exchange(this, timeToWait);
434510
}
435511

436512
@Override

spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcTestResult.java

+4-1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838
* {@linkplain #getMvcResult() result} will fail with an exception.</li>
3939
* </ol>
4040
*
41+
* <p>If the request was asynchronous, it is fully resolved at this point and
42+
* regular assertions can be applied without having to wait for the completion
43+
* of the response.
44+
*
4145
* @author Stephane Nicoll
4246
* @author Brian Clozel
4347
* @since 6.2
@@ -69,7 +73,6 @@ default MockHttpServletResponse getResponse() {
6973
return getMvcResult().getResponse();
7074
}
7175

72-
7376
/**
7477
* Return the exception that was thrown unexpectedly while processing the
7578
* request, if any.

spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcTestResultAssert.java

-12
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
import org.assertj.core.api.AbstractThrowableAssert;
2929
import org.assertj.core.api.Assertions;
3030
import org.assertj.core.api.MapAssert;
31-
import org.assertj.core.api.ObjectAssert;
3231
import org.assertj.core.error.BasicErrorMessageFactory;
3332
import org.assertj.core.internal.Failures;
3433

@@ -128,17 +127,6 @@ public MapAssert<String, Object> flash() {
128127
return new MapAssert<>(getMvcResult().getFlashMap());
129128
}
130129

131-
/**
132-
* Verify that {@linkplain AbstractHttpServletRequestAssert#hasAsyncStarted(boolean)
133-
* asynchronous processing has started} and return a new
134-
* {@linkplain ObjectAssert assertion} object that uses the asynchronous
135-
* result as the object to test.
136-
*/
137-
public ObjectAssert<Object> asyncResult() {
138-
request().hasAsyncStarted(true);
139-
return Assertions.assertThat(getMvcResult().getAsyncResult()).as("Async result");
140-
}
141-
142130
/**
143131
* Print {@link MvcResult} details to {@code System.out}.
144132
* <p>You must call it <b>before</b> calling the assertion otherwise it is ignored

spring-test/src/test/java/org/springframework/test/web/servlet/assertj/MockMvcTesterIntegrationTests.java

+74-10
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.io.PrintStream;
2121
import java.io.StringWriter;
2222
import java.nio.charset.StandardCharsets;
23+
import java.time.Duration;
2324
import java.util.Collections;
2425
import java.util.HashMap;
2526
import java.util.List;
@@ -36,6 +37,7 @@
3637
import jakarta.servlet.http.Part;
3738
import jakarta.validation.Valid;
3839
import jakarta.validation.constraints.Size;
40+
import org.assertj.core.api.InstanceOfAssertFactories;
3941
import org.junit.jupiter.api.AfterEach;
4042
import org.junit.jupiter.api.BeforeEach;
4143
import org.junit.jupiter.api.Nested;
@@ -53,6 +55,7 @@
5355
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
5456
import org.springframework.test.web.Person;
5557
import org.springframework.test.web.servlet.ResultMatcher;
58+
import org.springframework.test.web.servlet.assertj.MockMvcTester.MockMvcRequestBuilder;
5659
import org.springframework.ui.Model;
5760
import org.springframework.validation.Errors;
5861
import org.springframework.web.bind.annotation.GetMapping;
@@ -73,14 +76,16 @@
7376
import org.springframework.web.servlet.HandlerInterceptor;
7477
import org.springframework.web.servlet.ModelAndView;
7578
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
79+
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
7680
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
7781

7882
import static java.util.Map.entry;
7983
import static org.assertj.core.api.Assertions.assertThat;
8084
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
81-
import static org.assertj.core.api.InstanceOfAssertFactories.map;
85+
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
8286
import static org.mockito.Mockito.mock;
8387
import static org.mockito.Mockito.verify;
88+
import static org.mockito.Mockito.verifyNoInteractions;
8489

8590
/**
8691
* Integration tests for {@link MockMvcTester}.
@@ -97,12 +102,60 @@ public class MockMvcTesterIntegrationTests {
97102
this.mvc = MockMvcTester.from(wac);
98103
}
99104

105+
@Nested
106+
class PerformTests {
107+
108+
@Test
109+
void syncRequestWithDefaultExchange() {
110+
assertThat(mvc.get().uri("/greet")).hasStatusOk();
111+
}
112+
113+
@Test
114+
void asyncRequestWithDefaultExchange() {
115+
assertThat(mvc.get().uri("/streaming").param("timeToWait", "100")).hasStatusOk()
116+
.hasBodyTextEqualTo("name=Joe&someBoolean=true");
117+
}
118+
119+
@Test
120+
void syncRequestWithExplicitExchange() {
121+
assertThat(mvc.get().uri("/greet").exchange()).hasStatusOk();
122+
}
123+
124+
@Test
125+
void asyncRequestWithExplicitExchange() {
126+
assertThat(mvc.get().uri("/streaming").param("timeToWait", "100").exchange())
127+
.hasStatusOk().hasBodyTextEqualTo("name=Joe&someBoolean=true");
128+
}
129+
130+
@Test
131+
void syncRequestWithExplicitExchangeIgnoresDuration() {
132+
Duration timeToWait = mock(Duration.class);
133+
assertThat(mvc.get().uri("/greet").exchange(timeToWait)).hasStatusOk();
134+
verifyNoInteractions(timeToWait);
135+
}
136+
137+
@Test
138+
void asyncRequestWithExplicitExchangeAndEnoughTimeToWait() {
139+
assertThat(mvc.get().uri("/streaming").param("timeToWait", "100").exchange(Duration.ofMillis(200)))
140+
.hasStatusOk().hasBodyTextEqualTo("name=Joe&someBoolean=true");
141+
}
142+
143+
@Test
144+
void asyncRequestWithExplicitExchangeAndNotEnoughTimeToWait() {
145+
MockMvcRequestBuilder builder = mvc.get().uri("/streaming").param("timeToWait", "500");
146+
assertThatIllegalStateException()
147+
.isThrownBy(() -> builder.exchange(Duration.ofMillis(100)))
148+
.withMessageContaining("was not set during the specified timeToWait=100");
149+
}
150+
}
151+
100152
@Nested
101153
class RequestTests {
102154

103155
@Test
104156
void hasAsyncStartedTrue() {
105-
assertThat(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON))
157+
// Need #perform as the regular exchange waits for async completion automatically
158+
assertThat(mvc.perform(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON)))
106159
.request().hasAsyncStarted(true);
107160
}
108161

@@ -272,8 +325,10 @@ class BodyTests {
272325

273326
@Test
274327
void asyncResult() {
275-
assertThat(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON))
276-
.asyncResult().asInstanceOf(map(String.class, Object.class))
328+
// Need #perform as the regular exchange waits for async completion automatically
329+
MvcTestResult result = mvc.perform(mvc.get().uri("/callable").accept(MediaType.APPLICATION_JSON));
330+
assertThat(result.getMvcResult().getAsyncResult())
331+
.asInstanceOf(InstanceOfAssertFactories.map(String.class, Object.class))
277332
.containsOnly(entry("key", "value"));
278333
}
279334

@@ -441,12 +496,6 @@ void assertAndApplyWithUnresolvedException() {
441496
result -> assertThat(result).apply(mvcResult -> {}));
442497
}
443498

444-
@Test
445-
void assertAsyncResultWithUnresolvedException() {
446-
testAssertionFailureWithUnresolvableException(
447-
result -> assertThat(result).asyncResult());
448-
}
449-
450499
@Test
451500
void assertContentTypeWithUnresolvedException() {
452501
testAssertionFailureWithUnresolvableException(
@@ -617,6 +666,21 @@ static class AsyncController {
617666
public Callable<Map<String, String>> getCallable() {
618667
return () -> Collections.singletonMap("key", "value");
619668
}
669+
670+
@GetMapping("/streaming")
671+
StreamingResponseBody streaming(@RequestParam long timeToWait) {
672+
return out -> {
673+
PrintStream stream = new PrintStream(out, true, StandardCharsets.UTF_8);
674+
stream.print("name=Joe");
675+
try {
676+
Thread.sleep(timeToWait);
677+
stream.print("&someBoolean=true");
678+
}
679+
catch (InterruptedException e) {
680+
/* no-op */
681+
}
682+
};
683+
}
620684
}
621685

622686
@Controller

0 commit comments

Comments
 (0)