MockWebServer with JUnit: Test Spring WebClient Calls

Test Spring WebClient with MockWebServer and JUnit. Queue responses, route with a Dispatcher, verify requests, and simulate errors, delays and timeouts.

Sequence of a MockWebServer test with enqueue, WebClient request, queued response and takeRequest

MockWebServer is a small HTTP server from the OkHttp project that runs inside a test on a free local port and returns the responses we queue up for it. We point our HTTP client at the server’s URL, run the code under test, and check both the result and the request the client sent.

We use MockWebServer to test code that calls another service, such as a Spring WebClient that reads data from a partner API. The test needs no network and no running copy of the other service, and it can return errors, slow bodies or dropped connections whenever we want.

The following example queues one JSON response, sends a GET request with WebClient, and reads back the request that reached the server.

MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse.Builder().addHeader("Content-Type", "application/json").body("{\"name\":\"pancakes\",\"minutes\":20}").build());
server.start();                                   // binds a free random port
WebClient webClient = WebClient.create(server.url("/").toString());
String body = webClient.get().uri("/recipes/1").retrieve().bodyToMono(String.class).block();   // "{"name":"pancakes","minutes":20}"
RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS);
String method = request.getMethod();              // "GET"
String target = request.getTarget();              // "/recipes/1"
server.close();

Notice that we never choose a port. The call server.url(“/”) returns the address the server picked, so tests can run in parallel without port clashes. We set up MockWebServer 5.5.0 with JUnit and go through queued and routed responses, request checks, error cases and the alternatives.

1. Maven Dependencies for MockWebServer

OkHttp 5 ships MockWebServer in the mockwebserver3 artifact, with the package mockwebserver3. The optional mockwebserver3-junit5 artifact adds the @StartStop annotation that starts and closes the server for each test. Both work with JUnit 5 and JUnit 6, because both use the same Jupiter API.

<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>mockwebserver3</artifactId>
  <version>5.5.0</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>mockwebserver3-junit5</artifactId>
  <version>5.5.0</version>
  <scope>test</scope>
</dependency>

The examples use Spring Framework 7.0.9 (spring-webflux with Reactor Netty), Reactor Test 3.8.7 for StepVerifier, JUnit 6.1.3 and Java 25. In a Spring Boot 4 application, spring-boot-starter-webflux and spring-boot-starter-webflux-test bring all of them except MockWebServer.

Older tutorials import com.squareup.okhttp3:mockwebserver:4.x and the package okhttp3.mockwebserver. OkHttp 5 still publishes that artifact as a compatibility bridge, but new tests should use mockwebserver3, where several methods changed their names.

OkHttp 4 (okhttp3.mockwebserver)OkHttp 5 (mockwebserver3)
new MockResponse().setBody(“…”)new MockResponse.Builder().body(“…”).build()
setResponseCode(500)code(500)
setHeader(name, value)addHeader(name, value) or setHeader(name, value)
setBodyDelay(2, SECONDS)bodyDelay(2, SECONDS)
setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)onResponseStart(SocketEffect.ShutdownConnection.INSTANCE)
request.getPath()request.getTarget() or request.getUrl()
request.getBody().readUtf8()request.getBody().utf8()
server.shutdown()server.close()

2. Starting and Stopping MockWebServer in JUnit

A MockWebServer instance serves one test run and cannot be restarted after close(), because a second start() throws IllegalStateException. The simplest setup is a fresh server per test, so queued responses and recorded requests never leak from one test into the next.

Sequence of a MockWebServer test with enqueue, WebClient request, queued response and takeRequest
The test queues responses, the client calls the local server, and takeRequest() hands back what the client sent

The @StartStop annotation from mockwebserver3-junit5 does the lifecycle for us. JUnit starts the server before each test and closes it after the test, even when the test fails.

class RecipeServerLifecycleTest {

    @StartStop
    final MockWebServer server = new MockWebServer();

    @Test
    void serverRunsOnRandomPort() {
        assertTrue(server.getPort() > 0);
    }
}

Without the extra artifact, we write the two lifecycle methods ourselves with @BeforeEach and @AfterEach. A shared server in @BeforeAll is faster for large suites, but each test must consume every queued response and request, or the leftovers break the next test.

class ManualLifecycleTest {

    MockWebServer server;

    @BeforeEach
    void startServer() throws IOException {
        server = new MockWebServer();
        server.start();
    }

    @AfterEach
    void stopServer() {
        server.close();
    }

    @Test
    void serverIsStarted() {
        assertTrue(server.getStarted());
    }
}

Avoid server.start(8080). A fixed port fails when another process or a parallel test already uses it, and we gain nothing because server.url(“/”) always gives the right address.

3. The WebClient Code Under Test

Say a meal-planning app shows recipes that come from a separate recipe service. The app wraps the HTTP calls in a small client class, and that class is what we want to test. It reads a recipe by id, saves a new recipe with a bearer token, and gives up when the recipe service does not answer in time.

record Recipe(String name, int minutes) {}
class RecipeClient {

    private final WebClient webClient;
    private final Duration timeout;

    RecipeClient(String baseUrl, Duration timeout) {
        this.webClient = WebClient.builder().baseUrl(baseUrl).build();
        this.timeout = timeout;
    }

    Mono<Recipe> findRecipe(int id) {
        return webClient.get()
                .uri("/recipes/{id}", id)
                .retrieve()
                .bodyToMono(Recipe.class)
                .timeout(timeout);
    }

    Mono<Recipe> saveRecipe(Recipe recipe) {
        return webClient.post()
                .uri("/recipes")
                .header(HttpHeaders.AUTHORIZATION, "Bearer test-token")
                .bodyValue(recipe)
                .retrieve()
                .bodyToMono(Recipe.class)
                .timeout(timeout);
    }
}

The base URL is a constructor argument, which is what makes the class testable. In production it comes from configuration, and in the test it comes from server.url(“/”). The same idea applies to RestClient, RestTemplate, OkHttp or the JDK HttpClient, because MockWebServer is a real HTTP server and does not care which client calls it.

4. Queuing Responses with enqueue()

The enqueue() method adds a MockResponse to a first-in, first-out queue. Every incoming request takes the next response from the queue, whatever its path or method. A response without settings is an empty body with status 200.

MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse.Builder().body("first").build());
server.enqueue(new MockResponse.Builder().code(201).body("second").build());
server.start();
WebClient webClient = WebClient.create(server.url("/").toString());
String one = webClient.get().uri("/a").retrieve().bodyToMono(String.class).block();    // "first"
String two = webClient.get().uri("/b").retrieve().bodyToMono(String.class).block();    // "second"
int served = server.getRequestCount();             // 2
server.close();

We can see that the paths /a and /b play no role. The queue fits tests that make one call, or a fixed sequence of calls, such as a client that retries after a 503 and succeeds on the second attempt.

The next test uses the queue with our RecipeClient. It checks the parsed Recipe record and the request line in one test.

class RecipeClientTest {

    @StartStop
    final MockWebServer server = new MockWebServer();

    RecipeClient client;

    @BeforeEach
    void createClient() {
        client = new RecipeClient(server.url("/").toString(), Duration.ofMillis(500));
    }

    @Test
    void findsRecipeById() throws InterruptedException {
        server.enqueue(new MockResponse.Builder()
                .addHeader("Content-Type", "application/json")
                .body("{\"name\":\"pancakes\",\"minutes\":20}")
                .build());

        Recipe recipe = client.findRecipe(1).block();

        assertEquals(new Recipe("pancakes", 20), recipe);
        RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS);
        assertEquals("GET /recipes/1 HTTP/1.1", request.getRequestLine());
    }
}

The Content-Type header matters here. Without it, Spring treats the body as application/octet-stream, finds no decoder for a Recipe, and the call fails with a WebClientResponseException caused by UnsupportedMediaTypeException.

5. Routing Requests with a Dispatcher

A Dispatcher replaces the queue with our own routing logic. MockWebServer calls its dispatch() method for every request, and we return a response based on the method, path, headers or body. We use a dispatcher when the order of calls is not fixed, for example when the code under test sends parallel requests.

class RecipeDispatcher extends Dispatcher {

    @Override
    public MockResponse dispatch(RecordedRequest request) {
        String route = request.getMethod() + " " + request.getUrl().encodedPath();
        return switch (route) {
            case "GET /recipes/1" -> new MockResponse.Builder()
                    .addHeader("Content-Type", "application/json")
                    .body("{\"name\":\"pancakes\",\"minutes\":20}")
                    .build();
            case "POST /recipes" -> new MockResponse.Builder()
                    .code(201)
                    .addHeader("Content-Type", "application/json")
                    .body(request.getBody().utf8())
                    .build();
            default -> new MockResponse.Builder().code(404).build();
        };
    }
}

The POST route echoes the request body back, which is how many real APIs answer a create call. Any unknown route returns 404, so a typo in the client’s URL shows up as a failing test instead of a silent pass.

MockWebServer server = new MockWebServer();
server.setDispatcher(new RecipeDispatcher());
server.start();
RecipeClient client = new RecipeClient(server.url("/").toString(), Duration.ofSeconds(1));
Recipe pancakes = client.findRecipe(1).block();     // Recipe[name=pancakes, minutes=20]
Recipe saved = client.saveRecipe(new Recipe("waffles", 15)).block();   // Recipe[name=waffles, minutes=15]
int status = WebClient.create(server.url("/").toString()).get().uri("/recipes/9").exchangeToMono(r -> Mono.just(r.statusCode().value())).block();   // 404
server.close();

The getUrl() method returns an HttpUrl with the path and the query split apart. We match on encodedPath() so that a query string such as ?lang=en does not change the route.

6. Verifying the Request the Client Sent

Checking the response only proves that our code can parse JSON. The bugs we care about are often on the request side, such as a missing token, a wrong path or a field name that changed. The takeRequest() method returns the next RecordedRequest in the order the server received them.

MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse.Builder().code(201).addHeader("Content-Type", "application/json").body("{\"name\":\"waffles\",\"minutes\":15}").build());
server.start();
RecipeClient client = new RecipeClient(server.url("/").toString(), Duration.ofSeconds(1));
Recipe saved = client.saveRecipe(new Recipe("waffles", 15)).block();   // Recipe[name=waffles, minutes=15]
RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS);
String method = request.getMethod();                 // "POST"
String path = request.getUrl().encodedPath();        // "/recipes"
String auth = request.getHeaders().get("Authorization");    // "Bearer test-token"
String json = request.getBody().utf8();              // "{"name":"waffles","minutes":15}"
server.close();

Always pass a timeout to takeRequest() in tests. The no-argument version waits until a request arrives, so a client that never calls the server hangs the test run forever. With a timeout, takeRequest(1, TimeUnit.SECONDS) returns null after one second, and the next assertion fails with a clear message.

To compare JSON bodies without caring about field order or spaces, parse request.getBody().utf8() with Jackson, or use a JsonPath assertion on a single field. A plain string comparison breaks as soon as the serializer orders the fields differently.

7. Testing Errors, Slow Responses and Dropped Connections

The happy path is the least interesting part of an HTTP client. A real recipe service returns 500 during a deployment, answers slowly under load, or closes the connection when a proxy restarts. MockWebServer can play each of these cases, so we can test our error handling before production does it for us.

MockWebServer server = new MockWebServer();
server.enqueue(new MockResponse.Builder().code(500).build());
server.enqueue(new MockResponse.Builder().addHeader("Content-Type", "application/json").body("{}").bodyDelay(2, TimeUnit.SECONDS).build());
server.enqueue(new MockResponse.Builder().onResponseStart(SocketEffect.ShutdownConnection.INSTANCE).build());
server.start();
RecipeClient client = new RecipeClient(server.url("/").toString(), Duration.ofMillis(500));
Recipe failed = client.findRecipe(1).block();      // WebClientResponseException$InternalServerError: 500 Internal Server Error
Recipe slow = client.findRecipe(2).block();        // ReactiveException: java.util.concurrent.TimeoutException after 500ms
Recipe dropped = client.findRecipe(3).block();     // WebClientRequestException: Connection prematurely closed BEFORE response
server.close();

Each queued response produces a different exception, and each exception asks for different handling in the app.

MockResponse settingWhat the server doesWhat WebClient reports
code(500)Returns status 500 Internal Server ErrorWebClientResponseException.InternalServerError
bodyDelay(2, SECONDS) with a 500 ms timeout()Sends the headers and waits 2 seconds before the bodyTimeoutException, wrapped by block()
headersDelay(2, SECONDS)Waits before it sends the status line and headersTimeoutException with a timeout(), or a read timeout from the HTTP client
throttleBody(64, 1, SECONDS)Sends 64 bytes per secondA slow but successful response
onResponseStart(SocketEffect.ShutdownConnection.INSTANCE)Closes the connection before any responseWebClientRequestException

Keep delays short. A test with bodyDelay(2, SECONDS) and a 500 ms client timeout finishes after about half a second, because the client gives up first. For retry logic, queue a 503 followed by a 200 and assert both the result and server.getRequestCount(), as shown in our guide to retries with WebClient.

For reactive code, Reactor’s StepVerifier checks the Mono without calling block(). It subscribes, waits for the signals we describe and fails the test on any other signal.

class RecipeClientErrorTest {

    @StartStop
    final MockWebServer server = new MockWebServer();

    @Test
    void slowResponseTimesOut() {
        server.enqueue(new MockResponse.Builder()
                .addHeader("Content-Type", "application/json")
                .body("{}")
                .bodyDelay(2, TimeUnit.SECONDS)
                .build());
        RecipeClient client = new RecipeClient(server.url("/").toString(), Duration.ofMillis(300));

        StepVerifier.create(client.findRecipe(2))
                .expectError(TimeoutException.class)
                .verify(Duration.ofSeconds(5));
    }

    @Test
    void serverErrorIsReported() {
        server.enqueue(new MockResponse.Builder().code(500).build());
        RecipeClient client = new RecipeClient(server.url("/").toString(), Duration.ofSeconds(1));

        StepVerifier.create(client.findRecipe(1))
                .expectError(WebClientResponseException.InternalServerError.class)
                .verify(Duration.ofSeconds(5));
    }
}

The verify(Duration) overload puts an upper limit on the whole check, so a missing timeout in the client code fails the test instead of blocking the build. More options for client timeouts are in WebClient timeouts.

8. MockWebServer vs WireMock vs Mocking WebClient

Several tools can stand in for a remote API, and they test different things. Mocking the WebClient builder chain with Mockito never sends a request, so it misses wrong URLs, missing headers and JSON mapping errors. MockWebServer and WireMock both run a real HTTP server.

ToolHow it worksBest fit
MockWebServerQueue or dispatcher in plain Java, one server per testUnit tests of an HTTP client class, quick error and timeout cases
WireMockRequest matching DSL, JSON stub files, verification, recordingMany endpoints, shared stubs, stubs reused outside Java tests
Mockito mocks of WebClientReplaces the client object, no HTTP at allRarely useful, because it ties the test to the builder calls
WebTestClientCalls our own controllersTesting the server side of our app, not outgoing calls
MockRestServiceServerIntercepts RestTemplate and RestClient in memory@RestClientTest slices, no support for WebClient

MockWebServer is the lighter choice when one client class talks to one service. WireMock pays off when the stubs grow large, or when a whole microservices team shares them as JSON files.

9. MockWebServer FAQs

The typical MockWebServer question comes after the first green test, when people wire it into Spring Boot or hit a hanging build.

9.1. Does MockWebServer work with RestClient and RestTemplate?

Yes. MockWebServer is a real HTTP server on localhost, so any client works with it, including RestClient, RestTemplate, OkHttp, Retrofit and the JDK HttpClient. We only pass server.url(“/”) as the base URL.

9.2. Which port does MockWebServer use?

A free random port, picked by the operating system when we call start() without arguments. The method server.getPort() returns it, and server.url(“/”) returns the full base URL. The start(int port) overload exists, but a fixed port leads to clashes when tests run in parallel.

9.3. Why does takeRequest() hang?

The no-argument takeRequest() waits until a request arrives. If the client fails before it sends anything, or calls a different server, the test never ends. Use takeRequest(1, TimeUnit.SECONDS), which returns null after the timeout.

9.4. How do we use MockWebServer with @SpringBootTest?

Start the server in a static field and register its URL with @DynamicPropertySource, so the Spring context reads the mock URL instead of the real one. The method adds a property such as recipes.api.base-url with the value server.url(“/”).toString(), and our client bean reads that property. The @DynamicPropertySource method runs before the context starts, so the server must already be running at that point.

9.5. What is the difference between mockwebserver and mockwebserver3?

The mockwebserver artifact holds the OkHttp 4 API in the package okhttp3.mockwebserver, with setters such as setBody(). The mockwebserver3 artifact is the OkHttp 5 API with immutable MockResponse objects built by MockResponse.Builder, plus the mockwebserver3-junit5 extension. New tests should use mockwebserver3.

9.6. Can we reuse a MockWebServer after close()?

No. After close(), the instance cannot start again. Create a new instance per test, or let @StartStop do it.

10. Conclusion

MockWebServer gives each test its own HTTP server on a random port. We queue responses with enqueue() when the call order is fixed, and switch to a Dispatcher when it is not. The takeRequest() method, always with a timeout, lets us check the method, path, headers and body that our client sent.

The same server plays the failures that matter in production, such as 500 responses, slow bodies and dropped connections. With OkHttp 5, we build responses with MockResponse.Builder, read paths with getTarget() or getUrl(), and let @StartStop handle the lifecycle.

11. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.