Testing Java HTTP Clients with MockWebServer
When you build a Java service that talks to other services over HTTP, you eventually have to prove that your client behaves correctly. Does it retry on a 503? Does it parse a JSON body in the shape the upstream team actually ships? Does it set the right headers when calling an external endpoint in production? These are not theoretical questions for teams shipping in places like Sydney or Melbourne where a single broken integration with the ATO or a major bank's API can hold up a release.
A unit test that mocks the HttpClient interface directly is fast, but it does not tell you anything about the wire format. A live integration test against a staging environment is realistic, but it is also flaky, slow, and someone else's responsibility. The sweet spot is a local HTTP server that you control from your test code. That is exactly what MockWebServer is: a scriptable HTTP server from the OkHttp team that you start on a random port, queue responses on, and inspect requests against.
This article walks through how to use MockWebServer to test Java HTTP clients built on the JDK HttpClient, Apache HttpClient 5, and OkHttp itself. It covers setup, request verification, error simulation, JUnit 5 lifecycle wiring, and the patterns that pay off when your client code is doing real work in a CI pipeline running on a build agent somewhere in ap-southeast-2.
Setting up MockWebServer in a Maven or Gradle project
MockWebServer ships as com.squareup.okhttp3:mockwebserver on Maven Central. The artifact is small, has no transitive surprises, and pulls in the same OkHttp dependencies your client code already loads. If you already use OkHttp in production, you have nothing new to think about. If you use the JDK HttpClient or Apache HttpClient, you only need MockWebServer as a test dependency.
A typical Maven declaration looks like:
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
Gradle users add testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' and move on. There is no plugin, no agent, no special test runner. MockWebServer is just a class you instantiate in your test. That simplicity matters when you are wiring it into a build that also has to deal with Serenity reports, JaCoCo coverage, and a Jenkins pipeline that scans with SonarQube - one fewer moving part means one fewer thing that breaks at 3am before a release.
When you create a server, you call MockWebServer server = new MockWebServer() and then server.start(). The server picks an ephemeral port, which matters when you have multiple test classes spinning up servers in parallel. You get the base URL with server.url("/") and hand it to your client. There is also an enqueue() method that takes a MockResponse and a takeRequest() method that gives you back what the client sent. That two-method API is the entire surface you will use most of the time.
Stubs, request verification, and JSON fixtures
The basic pattern is to enqueue one or more responses before calling the code under test, then assert on the recorded requests after the call returns. The responses are consumed in the order they were enqueued, which means you can simulate a sequence of behaviour: first a success, then a 500, then a redirect, then a final success.
server.enqueue(new MockResponse()
.setBody("{\"id\":\"42\",\"name\":\"Alice\"}")
.addHeader("Content-Type", "application/json"));
User user = client.fetchUser("42");
RecordedRequest recorded = server.takeRequest();
assertEquals("/users/42", recorded.getPath());
assertEquals("GET", recorded.getMethod());
This is where MockWebServer earns its keep. The recorded request gives you the path, the method, the headers, and the body. You can assert on any of them. For teams that ship to production from Sydney and have to prove they hit the right endpoint with the right bearer token, that level of fidelity is the difference between "the test passed" and "we actually know the wire format is correct".
For JSON, you can either inline strings or load fixtures from src/test/resources. Loading fixtures is the better habit because it keeps test data separate from test logic. A common pattern is to put JSON files under src/test/resources/fixtures/users/get-user-200.json and load them with a small helper. It feels a bit ceremonial, but it pays off when the same fixture is reused across unit tests and component tests.
Simulating failures, timeouts, and slow responses
The interesting HTTP behaviours are the ones you cannot easily trigger against a real server. Connection refused, slow responses, partial bodies, malformed chunked encoding - all of these are one line of MockWebServer API away.
To simulate a 503, you write new MockResponse().setResponseCode(503).setBody("upstream busy"). To make the response hang for five seconds so you can assert that your client times out, you write new MockResponse().setBodyDelay(5, TimeUnit.SECONDS). To make the server close the socket halfway through the body, you write new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START). These primitives are exactly the kind of thing that hits you in production - say, a CDN edge node in Sydney returning a 502 during a deployment - and exactly the kind of thing your unit tests against HttpClient mocks cannot catch.
You can also simulate throttling by using throttleBody with a bytes-per-second argument, which lets you verify that your client's retry logic does not sit in a tight loop hammering the upstream. The Australian Open Banking regime, for instance, publishes fairly aggressive rate limits, and any client that fails to back off will get itself throttled within minutes of going live. MockWebServer is a cheap way to prove you handle that gracefully before you find out the hard way in a shared sandbox.
JUnit 5 lifecycle and parallel execution
MockWebServer is not opinionated about how you start and stop it. You can wrap it in a @BeforeEach and @AfterEach pair, a JUnit 5 extension, or a Spring @Component if you are using Spring. For most Java projects, the cleanest pattern is a JUnit 5 extension that starts the server, exposes the URL through a parameter resolver, and shuts the server down after the test.
public class MockWebServerExtension implements BeforeEachCallback, AfterEachCallback {
private final MockWebServer server = new MockWebServer();
public void beforeEach(ExtensionContext ctx) {
server.start();
}
public void afterEach(ExtensionContext ctx) {
server.shutdown();
}
public URL baseUrl() { return server.url("/").url(); }
}
If you run tests in parallel using JUnit 5's parallel execution or Surefire's forkCount and reuseForks settings, each test class gets its own server on its own port. There is no port collision. This matters on the larger builds that Australian banks and fintechs tend to run, where the build agent is squeezing every cycle out of a CI fleet and any shared state - like a port number - becomes a flake source.
You should still call server.shutdown() in an @AfterEach or equivalent. If your test throws before the cleanup runs, you can wrap the server in a try-with-resources block - the class implements Closeable - and use an AfterEachCallback to make sure it always closes.
How MockWebServer compares to WireMock, MockServer, and Hoverfly
There are a few different tools that solve the same problem, and the right choice depends on what you are doing. The comparison below covers the four most common options in the Java ecosystem on the dimensions that usually matter: how you write stubs, how you verify requests, whether it speaks HTTPS out of the box, how it handles state, and how heavy the dependency is.
| Feature | MockWebServer | WireMock | MockServer | Hoverfly |
|---|---|---|---|---|
| Stub language | Java or Kotlin | JSON, Java, REST API | JSON, Java, REST API | JSON, Java |
| Request matching | Manual via takeRequest | Built-in matchers | Built-in matchers | Built-in matchers |
| HTTPS out of the box | No, use useHttps | Yes | Yes | Yes |
| Stateful scenarios | Manual, queue order | Scenarios | State model | States |
| Proxy mode | No | Yes | Yes | Yes |
| Dependency weight | Very light | Medium | Medium-heavy | Light |
If you only need a programmable server for unit tests and you want zero configuration files, MockWebServer is hard to beat. If you need scenario-based stateful mocking or a standalone server you can point your integration tests at from a different module, WireMock or MockServer is a better fit. Hoverfly is the one to reach for when you want to record real traffic and replay it. The choice is rarely permanent; many teams use MockWebServer for unit tests and WireMock for component tests.
For more on API integration patterns and the kinds of bugs that show up in production, the article on Rails API testing advice walks through a Ruby example that translates cleanly into Java thinking. The constraints - serialise cleanly, fail fast on unexpected status codes, never trust the wire format - are universal.
Patterns that pay off in real codebases
Three patterns come up again and again in mature Java HTTP client codebases. The first is to wrap the MockWebServer behind a small test fixture so your test classes do not import MockWebServer directly. That fixture exposes stubJson(path, status, body) and assertRequest(method, path, body) methods. It keeps the test code readable and lets you swap out MockWebServer for WireMock later without rewriting every test.
The second pattern is to assert on what the client sent, not just what it received. A surprising amount of production breakage comes from a client sending the wrong Authorization header or missing User-Agent. takeRequest().getHeader("Authorization") and an AssertJ assertion is two lines that catch what most people miss.
The third pattern is to put your HTTP client tests under a separate source set and run them in a dedicated CI stage. They are slower than pure unit tests and faster than full integration tests, so they deserve their own Maven or Gradle source set and their own Surefire or Failsafe configuration. That separation also lets you skip them on local dev machines when developers are iterating quickly, which is a real win during a long arvo of debugging.
To put this into practice, the next concrete step is to pick one HTTP client in your codebase that does not have tests today, add mockwebserver as a test dependency, and write a single test that enqueues a 200 response, calls the client, and asserts on the recorded request. Once that compiles and goes green, the same shape extends to every other client in the project. The full pattern, including the JUnit 5 extension and the fixture wrapper, is documented on the Test Detective site with downloadable examples.