Testing Spring WebClient with MockWebServer
Test the HTTP boundary
An external API can return HTML when your client expects JSON. A response with HTTP 500 and a response with HTTP 200 containing an HTML error page are different failures. Sending Accept: application/json expresses a preference; the server can still send another format.
A local MockWebServer lets a test control the status, headers, and body while exercising the real WebClient. This avoids mocking every method in WebClient’s fluent interface and keeps the test independent of an external service.
The revised example below covers three contracts:
- JSON with HTTP 200 is decoded successfully.
- HTML with HTTP 200 is rejected as an unexpected response format.
- HTTP 500 remains an HTTP error, including when its body contains HTML.
Dependencies and reproduction environment
Use Java 17 or later and a Gradle application with Spring Boot 3.x dependency management. The example was executed with JDK 17, Gradle 8.4, the Spring Boot 3.5.7 BOM, and MockWebServer 4.12.0. These versions describe the reproduction environment; check the supported dependency versions for your production application separately.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
}
tasks.named('test') { useJUnitPlatform() }
A complete JUnit test class
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.junit.jupiter.api.Assertions.*;
class ExternalApiTest {
private MockWebServer server;
private WebClient client;
record Payload(String message) {}
@BeforeEach
void start() throws IOException {
server = new MockWebServer();
server.start();
client = WebClient.builder().baseUrl(server.url("/").toString()).build();
}
@AfterEach
void stop() throws IOException {
server.shutdown();
}
private Mono<Payload> fetch() {
return client.get().uri("/employee/100")
.accept(MediaType.APPLICATION_JSON)
.exchangeToMono(response -> {
if (response.statusCode().isError()) {
return response.createException().flatMap(Mono::error);
}
boolean json = response.headers().contentType()
.map(type -> MediaType.APPLICATION_JSON.isCompatibleWith(type)
|| type.getSubtype().endsWith("+json"))
.orElse(false);
if (!json) {
return response.releaseBody().then(Mono.<Payload>error(
new IllegalStateException("Expected JSON response")));
}
return response.bodyToMono(Payload.class);
});
}
@Test
void rejectsHtmlEvenWhenStatusIs200() throws Exception {
server.enqueue(new MockResponse().setResponseCode(200)
.setHeader("Content-Type", "text/html; charset=UTF-8")
.setBody("<html><body>Unknown failure</body></html>"));
StepVerifier.create(fetch())
.expectErrorMatches(error -> error instanceof IllegalStateException
&& error.getMessage().equals("Expected JSON response"))
.verify(Duration.ofSeconds(5));
RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS);
assertNotNull(request);
assertEquals("GET", request.getMethod());
assertEquals("/employee/100", request.getPath());
}
@Test
void preservesHttp500Error() {
server.enqueue(new MockResponse().setResponseCode(500)
.setHeader("Content-Type", "text/html")
.setBody("<html>Unknown failure</html>"));
StepVerifier.create(fetch())
.expectError(org.springframework.web.reactive.function.client.WebClientResponseException.InternalServerError.class)
.verify(Duration.ofSeconds(5));
}
@Test
void decodesJson() {
server.enqueue(new MockResponse().setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("{\"message\":\"ok\"}"));
StepVerifier.create(fetch())
.expectNextMatches(payload -> payload.message().equals("ok"))
.expectComplete().verify(Duration.ofSeconds(5));
}
}
Why these details matter
setBody(html) sends an HTML document. Serializing that string with Jackson first would wrap it as a JSON string, changing the response you intended to test.
The client checks the HTTP status before decoding a successful response. response.createException() preserves an HTTP 500 as a WebClientResponseException.InternalServerError. A connection failure belongs to the request-failure path, usually represented by WebClientRequestException; it should not be confused with an HTTP error returned by a server.
For a successful status, the example requires JSON or a structured +json subtype. If that contract fails, it releases the response body before reporting an application error. exchangeToMono gives the test control over how the response is consumed.
verify(Duration.ofSeconds(5)) bounds the test’s waiting time. Configure connection and response timeouts separately for the production HTTP client. Additional tests should cover an empty body, malformed JSON, a missing Content-Type, a problem-details response, and timeouts according to your API’s contract.
Run the published example
The three tests passed after extracting this exact class from the Korean article. In this repository, run python3 scripts/check_webclient_example.py with Gradle and JDK 17 installed. The verification does not require a running Spring application or a live external API.
Discussion