diff --git a/framework-docs/src/docs/asciidoc/testing/spring-mvc-test-client.adoc b/framework-docs/src/docs/asciidoc/testing/spring-mvc-test-client.adoc index 83eaecc40a6..83f09bd3e2b 100644 --- a/framework-docs/src/docs/asciidoc/testing/spring-mvc-test-client.adoc +++ b/framework-docs/src/docs/asciidoc/testing/spring-mvc-test-client.adoc @@ -117,6 +117,56 @@ logic but without running a server. The following example shows how to do so: // Test code that uses the above RestTemplate ... ---- +In the more specific cases where total isolation isn't desired and some integration testing +of one or more calls is needed, a specific `ResponseCreator` can be set up in advance and +used to perform actual requests and assert the response. +The following example shows how to set up and use the `ExecutingResponseCreator` to do so: + +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +.Java +---- + RestTemplate restTemplate = new RestTemplate(); + + // Make sure to capture the request factory of the RestTemplate before binding + ExecutingResponseCreator withActualResponse = new ExecutingResponseCreator(restTemplate.getRequestFactory()); + + MockRestServiceServer mockServer = MockRestServiceServer.bindTo(restTemplate).build(); + mockServer.expect(requestTo("/greeting")).andRespond(withActualResponse); + + // Test code that uses the above RestTemplate ... + + mockServer.verify(); +---- +[source,kotlin,indent=0,subs="verbatim,quotes",role="secondary"] +.Kotlin +---- + val restTemplate = RestTemplate() + + // Make sure to capture the request factory of the RestTemplate before binding + val withActualResponse = new ExecutingResponseCreator(restTemplate.getRequestFactory()) + + val mockServer = MockRestServiceServer.bindTo(restTemplate).build() + mockServer.expect(requestTo("/profile")).andRespond(withSuccess()) + mockServer.expect(requestTo("/quoteOfTheDay")).andRespond(withActualResponse) + + // Test code that uses the above RestTemplate ... + + mockServer.verify() +---- + +In the preceding example, we create the `ExecutingResponseCreator` using the +`ClientHttpRequestFactory` from the `RestTemplate` _before_ `MockRestServiceServer` replaces +it with the custom one. +Then we define expectations with two kinds of response: + + * a stub `200` response for the `/profile` endpoint (no actual request will be executed) + * an "executing response" for the `/quoteOfTheDay` endpoint + +In the second case, the request is executed by the `ClientHttpRequestFactory` that was +captured earlier. This generates a response that could e.g. come from an actual remote server, +depending on how the `RestTemplate` was originally configured, and MockMVC can be further +used to assert the content of the response. + [[spring-mvc-test-client-static-imports]] == Static Imports diff --git a/spring-test/src/main/java/org/springframework/test/web/client/response/ExecutingResponseCreator.java b/spring-test/src/main/java/org/springframework/test/web/client/response/ExecutingResponseCreator.java new file mode 100644 index 00000000000..deb69a3b63c --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/client/response/ExecutingResponseCreator.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2023 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.web.client.response; + +import java.io.IOException; + +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.test.web.client.ResponseCreator; +import org.springframework.util.Assert; +import org.springframework.util.StreamUtils; + +/** + * A {@code ResponseCreator} which delegates to a {@link ClientHttpRequestFactory} + * to perform the request and return the associated response. + * This is notably useful when testing code that calls multiple remote services, some + * of which need to be actually called rather than further mocked. + *

Note that the input request is asserted to be a {@code MockClientHttpRequest} and + * the URI, method, headers and body are copied. + *

The factory can typically be obtained from a {@code RestTemplate} but in case this + * is used with e.g. {@code MockRestServiceServer}, make sure to capture the factory early + * before binding the mock server to the RestTemplate (as it replaces the factory): + *


+ * ResponseCreator withActualResponse = new ExecutingResponseCreator(restTemplate);
+ * MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
+ * //...
+ * server.expect(requestTo("/foo")).andRespond(withSuccess());
+ * server.expect(requestTo("/bar")).andRespond(withActualResponse);
+ * 
+ * + * @author Simon Baslé + * @since 6.0.4 + */ +public class ExecutingResponseCreator implements ResponseCreator { + + private final ClientHttpRequestFactory requestFactory; + + + /** + * Create a {@code ExecutingResponseCreator} from a {@code ClientHttpRequestFactory}. + * @param requestFactory the request factory to delegate to + */ + public ExecutingResponseCreator(ClientHttpRequestFactory requestFactory) { + this.requestFactory = requestFactory; + } + + + @Override + public ClientHttpResponse createResponse(ClientHttpRequest request) throws IOException { + Assert.state(request instanceof MockClientHttpRequest, "Request should be an instance of MockClientHttpRequest"); + MockClientHttpRequest mockRequest = (MockClientHttpRequest) request; + ClientHttpRequest newRequest = this.requestFactory.createRequest(mockRequest.getURI(), mockRequest.getMethod()); + newRequest.getHeaders().putAll(mockRequest.getHeaders()); + StreamUtils.copy(mockRequest.getBodyAsBytes(), newRequest.getBody()); + return newRequest.execute(); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/client/response/MockRestResponseCreators.java b/spring-test/src/main/java/org/springframework/test/web/client/response/MockRestResponseCreators.java index 3a34e0a58c3..38ea2c1e3eb 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/response/MockRestResponseCreators.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/response/MockRestResponseCreators.java @@ -33,8 +33,15 @@ import org.springframework.test.web.client.ResponseCreator; *

Eclipse users: consider adding this class as a Java editor * favorite. To navigate, open the Preferences and type "favorites". * + *

See also {@link ExecutingResponseCreator} for a {@code ResponseCreator} that is + * capable of performing an actual request. That case is not offered as a factory method + * here because of the early setup that is likely needed (capturing a request factory + * which wouldn't be available anymore when the factory methods are typically invoked, + * e.g. replaced in a {@code RestTemplate} by the {@code MockRestServiceServer}). + * * @author Rossen Stoyanchev * @since 3.2 + * @see ExecutingResponseCreator */ public abstract class MockRestResponseCreators { diff --git a/spring-test/src/test/java/org/springframework/test/web/client/MockRestServiceServerTests.java b/spring-test/src/test/java/org/springframework/test/web/client/MockRestServiceServerTests.java index 36c783f4279..c8f536c9aaf 100644 --- a/spring-test/src/test/java/org/springframework/test/web/client/MockRestServiceServerTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/client/MockRestServiceServerTests.java @@ -17,13 +17,22 @@ package org.springframework.test.web.client; import java.net.SocketException; +import java.nio.charset.StandardCharsets; import java.time.Duration; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; import org.springframework.test.web.client.MockRestServiceServer.MockRestServiceServerBuilder; +import org.springframework.test.web.client.response.ExecutingResponseCreator; import org.springframework.web.client.RestTemplate; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.fail; @@ -88,6 +97,43 @@ class MockRestServiceServerTests { server.verify(); } + @Test + void executingResponseCreator() { + RestTemplate restTemplateWithMockEcho = createEchoRestTemplate(); + + final ExecutingResponseCreator withActualCall = new ExecutingResponseCreator(restTemplateWithMockEcho.getRequestFactory()); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplateWithMockEcho).build(); + server.expect(requestTo("/profile")).andRespond(withSuccess()); + server.expect(requestTo("/quoteOfTheDay")).andRespond(withActualCall); + + var response1 = restTemplateWithMockEcho.getForEntity("/profile", String.class); + var response2 = restTemplateWithMockEcho.getForEntity("/quoteOfTheDay", String.class); + server.verify(); + + assertThat(response1.getStatusCode().value()) + .as("response1 status").isEqualTo(200); + assertThat(response1.getBody()) + .as("response1 body").isNullOrEmpty(); + assertThat(response2.getStatusCode().value()) + .as("response2 status").isEqualTo(300); + assertThat(response2.getBody()) + .as("response2 body").isEqualTo("echo from /quoteOfTheDay"); + } + + private static RestTemplate createEchoRestTemplate() { + final ClientHttpRequestFactory echoRequestFactory = (uri, httpMethod) -> { + final MockClientHttpRequest req = new MockClientHttpRequest(httpMethod, uri); + String body = "echo from " + uri.getPath(); + final ClientHttpResponse resp = new MockClientHttpResponse(body.getBytes(StandardCharsets.UTF_8), + // Instead of 200, we use a less-common status code on purpose + HttpStatus.MULTIPLE_CHOICES); + resp.getHeaders().setContentType(MediaType.TEXT_PLAIN); + req.setResponse(resp); + return req; + }; + return new RestTemplate(echoRequestFactory); + } + @Test void resetAndReuseServer() { MockRestServiceServer server = MockRestServiceServer.bindTo(this.restTemplate).build(); diff --git a/spring-test/src/test/java/org/springframework/test/web/client/response/ExecutingResponseCreatorTests.java b/spring-test/src/test/java/org/springframework/test/web/client/response/ExecutingResponseCreatorTests.java new file mode 100644 index 00000000000..4c5efcc8975 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/client/response/ExecutingResponseCreatorTests.java @@ -0,0 +1,124 @@ +/* + * Copyright 2002-2023 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.test.web.client.response; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.AbstractClientHttpRequest; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for the {@link ExecutingResponseCreator} implementation. + * + * @author Simon Baslé + */ +class ExecutingResponseCreatorTests { + + @Test + void ensureRequestNotNull() { + final ExecutingResponseCreator responseCreator = new ExecutingResponseCreator((uri, method) -> null); + + assertThatIllegalStateException() + .isThrownBy(() -> responseCreator.createResponse(null)) + .withMessage("Request should be an instance of MockClientHttpRequest"); + } + + @Test + void ensureRequestIsMock() { + final ExecutingResponseCreator responseCreator = new ExecutingResponseCreator((uri, method) -> null); + ClientHttpRequest notAMockRequest = new AbstractClientHttpRequest() { + @Override + protected OutputStream getBodyInternal(HttpHeaders headers) throws IOException { + return null; + } + + @Override + protected ClientHttpResponse executeInternal(HttpHeaders headers) throws IOException { + return null; + } + + @Override + public HttpMethod getMethod() { + return null; + } + + @Override + public URI getURI() { + return null; + } + }; + + assertThatIllegalStateException() + .isThrownBy(() -> responseCreator.createResponse(notAMockRequest)) + .withMessage("Request should be an instance of MockClientHttpRequest"); + } + + @Test + void requestIsCopied() throws IOException { + MockClientHttpRequest originalRequest = new MockClientHttpRequest(HttpMethod.POST, + "https://example.org"); + String body = "original body"; + originalRequest.getHeaders().add("X-example", "original"); + originalRequest.getBody().write(body.getBytes(StandardCharsets.UTF_8)); + MockClientHttpResponse originalResponse = new MockClientHttpResponse(new byte[0], 500); + List factoryRequests = new ArrayList<>(); + ClientHttpRequestFactory originalFactory = (uri, httpMethod) -> { + MockClientHttpRequest request = new MockClientHttpRequest(httpMethod, uri); + request.setResponse(originalResponse); + factoryRequests.add(request); + return request; + }; + + final ExecutingResponseCreator responseCreator = new ExecutingResponseCreator(originalFactory); + final ClientHttpResponse response = responseCreator.createResponse(originalRequest); + + assertThat(response).as("response").isSameAs(originalResponse); + assertThat(originalRequest.isExecuted()).as("originalRequest.isExecuted").isFalse(); + + assertThat(factoryRequests) + .hasSize(1) + .first() + .isNotSameAs(originalRequest) + .satisfies(copiedRequest -> { + assertThat(copiedRequest) + .as("copied request") + .isNotSameAs(originalRequest); + assertThat(copiedRequest.isExecuted()) + .as("copiedRequest.isExecuted").isTrue(); + assertThat(copiedRequest.getBody()) + .as("copiedRequest.body").isNotSameAs(originalRequest.getBody()); + assertThat(copiedRequest.getHeaders()) + .as("copiedRequest.headers").isNotSameAs(originalRequest.getHeaders()); + }); + } +}