Definition
- MockWebServer – a real, lightweight HTTP server from OkHttp that binds to a local port; you enqueue scripted responses, point your outbound client at it, and assert the exact request sent.
- WebTestClient – an in-memory test client for your own inbound endpoints, used with
@WebFluxTestand@MockBeanto verify routing, validation, and response serialization. - Direction rule – if the thing under test calls OUT to another service, use MockWebServer; if the thing under test receives INBOUND requests, use WebTestClient.
- Complementary – both are used in the same test suite for different seams: MockWebServer at the back door, WebTestClient at the front door.
Think of National Bank of India's payments service as a bank teller. MockWebServer is a fake phone line pretending to be the credit bureau: you listen to exactly what the teller says and control what the bureau replies (testing outgoing calls). WebTestClient is playing the customer who walks up to the teller's counter with a transfer form: you verify the teller validates it, rejects bad forms, and processes good ones (testing incoming requests).
Understanding the Concepts
What decides which tool to use?
The direction of the call is the whole decision. If the thing under test calls OUT to another service, use MockWebServer. If the thing under test receives INBOUND requests, use WebTestClient.
Direction determines the tool:
┌───────────────────┐
Incoming ────▶ │ Your Controller │ ────▶ Outgoing
requests │ (payments API) │ calls
└───────────────────┘
│ │
▼ ▼
WebTestClient MockWebServer
(test YOUR endpoints) (fake the DOWNSTREAM)
How does MockWebServer work?
MockWebServer is a real HTTP server from the OkHttp project that binds to an ephemeral local port. You enqueue scripted responses, point your client at the server's URL, and assert the recorded request.
package com.bank.client;
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.web.reactive.function.client.WebClient;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.*;
class CreditScoreClientTest {
private MockWebServer server;
private CreditScoreClient client;
@BeforeEach
void setUp() throws IOException {
server = new MockWebServer();
server.start();
String baseUrl = server.url("/").toString();
client = new CreditScoreClient(WebClient.create(baseUrl));
}
@AfterEach
void tearDown() throws IOException {
server.shutdown();
}
@Test
void fetchScore_sendsCorrectRequest() throws Exception {
// Arrange: enqueue a successful response
server.enqueue(new MockResponse()
.setBody("{\"accountId\":\"SAV2001\",\"score\":750}")
.addHeader("Content-Type", "application/json"));
// Act: call the client
int score = client.fetchScore("SAV2001");
// Assert: verify the request our code sent
RecordedRequest request = server.takeRequest();
assertEquals("GET", request.getMethod());
assertEquals("/scores/SAV2001", request.getPath());
assertEquals(750, score);
}
@Test
void fetchScore_500_throwsDownstreamException() throws Exception {
// Simulate downstream failure
server.enqueue(new MockResponse().setResponseCode(500));
assertThrows(DownstreamException.class, () -> client.fetchScore("SAV2001"));
}
@Test
void fetchScore_404_throwsBureauNotFoundException() throws Exception {
server.enqueue(new MockResponse().setResponseCode(404));
assertThrows(BureauNotFoundException.class, () -> client.fetchScore("SAV2001"));
}
@Test
void fetchScore_timeout_throwsTimeoutException() throws Exception {
// Simulate slow server -- verify our timeout fires
server.enqueue(new MockResponse()
.setBody("{\"score\":750}")
.setBodyDelay(5, java.util.concurrent.TimeUnit.SECONDS));
// Client has a 2-second timeout configured
assertThrows(java.util.concurrent.TimeoutException.class,
() -> client.fetchScore("SAV2001"));
}
}
How does WebTestClient work?
WebTestClient is an in-memory test client for your own controllers. With @WebFluxTest, Spring loads only the web layer.
package com.bank.controller;
import com.bank.model.TransferRequest;
import com.bank.service.TransferService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@WebFluxTest(TransferController.class)
class TransferControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockBean
private TransferService transferService;
@Test
void transfer_validRequest_returns201() {
when(transferService.transfer(any()))
.thenReturn(Mono.just("TXN-12345"));
TransferRequest request = new TransferRequest(
"SAV2001", "SAV2002", new BigDecimal("5000.00"));
webTestClient.post()
.uri("/api/v1/transfers")
.bodyValue(request)
.exchange()
.expectStatus().isCreated()
.expectBody()
.jsonPath("$.transactionId").isEqualTo("TXN-12345")
.jsonPath("$.status").isEqualTo("COMPLETED");
}
@Test
void transfer_missingAccountId_returns400() {
TransferRequest request = new TransferRequest(
null, "SAV2002", new BigDecimal("5000.00"));
webTestClient.post()
.uri("/api/v1/transfers")
.bodyValue(request)
.exchange()
.expectStatus().isBadRequest();
}
}
How do MockWebServer and WebTestClient compare?
| Aspect | MockWebServer | WebTestClient |
|--------|--------------|---------------|
| Tests | Outbound calls (your client code) | Inbound requests (your controller) |
| What it is | Real HTTP server on local port | In-memory test client |
| What it fakes | The downstream service | Nothing (drives your endpoint) |
| Asserts | Request method, path, headers, body | Response status, body, validation |
| Simulates | 500, 404, timeout, slow response | N/A (it is the caller, not the responder) |
| Spring integration | None (standalone) | @WebFluxTest + @MockBean |
| Exercises | Full HTTP client stack (connection, serialization) | Full web layer (routing, validation, serialization) |
How do you combine both in a full slice test?
For a test that spans your controller to a real downstream, use WebTestClient at the front door and MockWebServer at the back.
package com.bank.integration;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;
import java.io.IOException;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FullSliceTest {
@Autowired
private WebTestClient webTestClient;
private MockWebServer creditBureauServer;
@BeforeEach
void setUp() throws IOException {
creditBureauServer = new MockWebServer();
creditBureauServer.start(8089); // Bureau URL configured to localhost:8089
}
@AfterEach
void tearDown() throws IOException {
creditBureauServer.shutdown();
}
@Test
void eligibility_bureauReturnsHighScore_approved() {
// MockWebServer fakes the bureau (outbound)
creditBureauServer.enqueue(new MockResponse()
.setBody("{\"score\":800}")
.addHeader("Content-Type", "application/json"));
// WebTestClient drives the endpoint (inbound)
webTestClient.post()
.uri("/api/v1/eligibility")
.bodyValue("{\"accountId\":\"SAV2001\"}")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.decision").isEqualTo("APPROVED");
}
}
What are common mistakes when choosing the tool?
# WRONG: using WebTestClient to test outbound resilience @WebFluxTest(TransferController.class) // This tests your CONTROLLER, not your outbound WebClient // It tells you nothing about retry logic, timeout, or 500 handling # WRONG: using MockWebServer to test your routing server.enqueue(new MockResponse().setResponseCode(200)); // This tests the DOWNSTREAM, not your controller's routing or validation # CORRECT: match the tool to the direction // Outbound client (calls credit bureau) ──▶ MockWebServer // Inbound controller (receives transfer request) ──▶ WebTestClient
What are common interview follow-up questions?
- How do you verify that MockWebServer received the expected request headers and body?
- Can WebTestClient test a non-reactive Spring MVC controller?
- How do you simulate network timeouts and connection failures in MockWebServer?
- When would you use WireMock instead of MockWebServer?
What should a senior engineer know?
The direction of the call decides the tool. MockWebServer exercises the full outbound HTTP client stack: connection, serialization, status handling, timeout, and retry. WebTestClient exercises the full inbound web layer: routing, request deserialization, bean validation, status codes, and response serialization. Using the wrong one tests nothing useful about the seam you care about.
Remember to shut down MockWebServer in teardown and to assert the recorded request, otherwise you only tested that a response came back, not that you sent the right request.
// Always assert the recorded request
RecordedRequest request = server.takeRequest();
assertEquals("GET", request.getMethod());
assertEquals("/scores/SAV2001", request.getPath());
assertTrue(request.getHeader("Authorization").startsWith("Bearer "));
// Without this, you only verified the response, not the request your code built
Visual Overview
Tool Selection by Call Direction
==================================
Inbound Outbound
(others call YOU) (YOU call others)
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ WebTestClient│ │MockWebServer │
│ │ │ │
│ Drives YOUR │ │ Fakes the │
│ controller │ │ DOWNSTREAM │
│ │ │ │
│ Asserts: │ │ Asserts: │
│ - status │ │ - request │
│ - body │ │ method │
│ - validation │ │ - path │
│ │ │ - headers │
│ Sliced: │ │ │
│ @WebFluxTest │ │ Simulates: │
│ + @MockBean │ │ - 500, 404 │
│ │ │ - timeout │
│ │ │ - retry │
└──────────────┘ └──────────────┘
Full Slice Test (both tools):
WebTestClient ──▶ Your Controller ──▶ Your Client ──▶ MockWebServer
(front door) (real code) (real code) (fake bureau)
Key Takeaway
MockWebServer and WebTestClient solve opposite problems. MockWebServer is a real HTTP server you point your outbound client at to test the calls your service makes to downstream services: you enqueue scripted responses, verify the exact request your code sent, and simulate failure like 500s, 404s, and timeouts. WebTestClient drives your own inbound endpoints with @WebFluxTest and mocked collaborators to verify routing, validation, status codes, and response serialization.
The one-line rule is that MockWebServer tests outbound calls and WebTestClient tests inbound calls. The classic mistake is swapping them: using WebTestClient to test outbound resilience tells you nothing about your client, and using MockWebServer to test your routing tells you nothing about your endpoints.
At National Bank of India, our loan-eligibility service calls an external credit bureau that is flaky and costs money per call. We used MockWebServer to simulate bureau failures and verify retry and timeout behavior, and WebTestClient to verify our eligibility endpoint handles validation and returns the correct decisions. For full end-to-end slices we combined both: WebTestClient at the front door and MockWebServer at the back.
Remember one sentence: the direction of the call decides the tool: MockWebServer for outbound to faked downstreams, WebTestClient for inbound to your own controllers.
---