Making HTTP Client Tests Cleaner with MockWebServer and kiwi-test

Posted on March 09, 2025 by Scott Leberknight

In the previous blog, I showed using MockWebServer (part of OkHttp) to test HTTP client code. The test code was pretty clean and simple, but there are a few minor annoyances:

  1. The boilerplate invocation to get the URI of the MockWebServer
  2. Having to deal with InterruptedException using takeRequest
  3. Needing to assert that the RecordedRequest returned from takeRequest is not null
  4. Wrapping the assertions on a RecordedRequest in assertAll versus having an AssertJ-style fluent API

I fully admit these are all minor. However, the more I used MockWebServer the more I wanted to:

  1. Reduce boilerplate code
  2. Not need to deal with InterruptedException in tests
  3. Not have to null-check the RecordedRequest
  4. Have a fluent assertion API for RecordedRequest

In addition, there's another "gotcha" which is that if you use the no-argument takeRequest() method, your tests might never end. From the Javadoc, the takeRequest() method "will block until the request is available, possibly forever". (emphasis mine). It actually happened to me a few times before I actually read the Javadocs! After that I decided to only use the takeRequest method that accepts a timeout. This fixes the "never ends" problem. But whichever of the takeRequest methods you use, they both throw InterruptedException which you need to handle (unless you are using Kotlin in which case you don't need to worry about it).

To resolve the above "problems" I added several test utilities to kiwi-test in release 3.5.0 last July:

  • MockWebServers
  • MockWebServerAssertions
  • RecordedRequests
  • RecordedRequestAssertions

MockWebServers

This currently contains only two overloaded methods named uri. These are convenience methods to get the URI for a MockWebServer, either with or without a path. For example, instead of:

this.baseUri = server.url("/math").uri();

you can do this:

this.baseUri = MockWebServers.uri(server, "/math");

And with a static import for MockWebServers, the code is even shorter.

Is this small amount of boilerplate really worth these methods? Maybe, maybe not. Once I had written similar code a few dozen times, I decided it was worth having methods that accomplished the same thing.

Generally, I use these methods in @BeforeEach methods and store the value in a field, so that all tests can easily access it. Sometimes you don't need to store it in a field, but instead just pass it to the HTTP client:

var baseUri = MockWebServers.uri(server, "/math");
this.mathClient = new MathApiClient(client, baseUri);

In this example, the mathClient is stored in a field and each test uses it.

MockWebServerAssertions

This class is a starting point for assertions on a MockWebServer. It contains a few static factory methods to start from, one named assertThat and one named assertThatMockWebServer. The reason for the second one is to avoid conflicts with AssertJ's Assertions#assertThat methods. It provides a way to assert the number of requests made to the MockWebServer and has several other methods to assert on RecordedRequest. For example, assuming you use a static import:

assertThatMockWebServer(server)
        .hasRequestCount(1)
        .recordedRequest()
        .isGET()
        .hasPath("/status");

This code verifies that exactly one request was made, then uses the recordedRequest() method to get the RecordedRequest, and finally makes assertions that the request was a GET with path /status.

If you want to verify more than one request, you can use the hasRecordedRequest. The following code verifies that there were two requests made, and checks each one in the Consumer that is passed to hasRecordedRequest:

var path1 = "...";
var path2 = "...";
var requestBody = "{ ... }";

assertThatMockWebServer(server)
        .hasRequestCount(2)
        .hasRecordedRequest(recordedRequest1 -> {
            assertThat(recordedRequest1.getMethod()).isEqualTo("GET");
            assertThat(recordedRequest1.getPath()).isEqualTo(path1);
        })
        .hasRecordedRequest(recordedRequest2 -> {
            assertThat(recordedRequest2.getMethod()).isEqualTo("POST");
            assertThat(recordedRequest2.getPath()).isEqualTo(path2);
            assertThat(recordedRequest2.getBody().readUtf8()).isEqualTo(requestBody);
        });

RecordedRequests

While MockWebServers and MockWebServerAssertions are useful, RecordedRequests and RecordedRequestAssertions (discussed below) are the tools I use most when writing HTTP client tests.

RecordedRequests contains several methods to get a RecordedRequest from a MockWebServer. The method to use depends on whether there must be a request, or whether there may or may not be a request. If a request is required, you can use takeRequiredRequest:

var recordedRequest = takeRequiredRequest(server);

// make assertions on the RecordedRequest instance

But if it's possible that there might not be a request, you can use either takeRequestOrEmpty or takeRequestOrNull. The former returns Optional<RecordedRequest> while the latter returns a (possibly null) RecordedRequest. For example, if some business logic makes a request but only when certain requirements are met, a test can use one of these two methods:

// work with an Optional<RecordedRequest>
var maybeRequest = takeRequestOrEmpty(server);
assertThat(maybeRequest).isEmpty();

// or with a RecordedRequest directly
var request = takeRequestOrNull(server);
assertThat(request).isNull();

But wait, there's more. Not much, but there is another method assertNoMoreRequests that does what you expect: it verifies the MockWebServer does not contain any additional requests. So, once you have checked one or more requests, you can call it to verify the client didn't do anything else unexpected:

// get and assert one or more RecordedRequest

// now, verify there weren't any additional requests
assertNoMoreRequests(server);

As mentioned in the introduction, the RecordedRequest#takeRequest() method blocks, possibly forever. RecordedRequests avoids this problem by assuming all requests should already have been made by the time you want to get a request and make assertions on it.

Under the hood, all RecordedRequests methods call takeRequest(timeout: Long, unit: TimeUnit) (it's Kotlin, so the argument name is first and the type is second) and only wait 10 milliseconds before giving up. They handle InterruptedException by catching it, re-interrupting the current thread, and throwing an UncheckedInterruptedException (from the kiwi library). This allows for cleaner test code without needing to catch InterruptedException or declare a throws clause. So, your test code can just do this without worrying about timeouts:

var recordedRequest = RecordedRequests.takeRequiredRequest(server);

RecordedRequestAssertions

You use the methods in RecordedRequests to get one or more RecordedRequest to make assertions on. You can use RecordedRequestAssertions to make these assertions in a fluent-style API like AssertJ. If you don't like the AssertJ assertion chaining style, you can skip this section and move on with life. But if you like AssertJ, read on.

RecordedRequestAssertions contains several static methods to start from, and a number of assertion methods to check things like the request method, path, URI, and body. For example, suppose you are using the "Math API" from the previous blog and want to test addition. You can do this:

assertThatRecordedRequest(recordedRequest)
        .isGET()
        .hasPath("/math/add/40/2")
        .hasNoBody();

Here you are checking that a GET request was made to the server with path /math/add/40/2, and that there was no request body (since GET requests should in general not have one).

You can also verify the request body. Suppose you have a "User API" to perform various actions. To test a request sent to the "Create User" endpoint, you can write a test like this:

@Test
void shouldCreateUser() {
    var id = RandomGenerator.getDefault().nextLong(1, 501);
    var responseEntity = User.newWithRedactedPassword(id, "s_white", "Shaun White");

    server.enqueue(new MockResponse()
            .setResponseCode(201)
            .setHeader(HttpHeaders.CONTENT_TYPE, "application/json")
            .setHeader(HttpHeaders.LOCATION, UriBuilder.fromUri(baseUri).path("/users/{id}").build(id))
            .setBody(JSON_HELPER.toJson(responseEntity)));

    var newUser = new User(null, "s_white", "snowboarding", "Shaun White");
    var createdUser = apiClient.create(newUser);

    assertAll(
            () -> assertThat(createdUser.id()).isEqualTo(id),
            () -> assertThat(createdUser.username()).isEqualTo("s_white"),
            () -> assertThat(createdUser.password()).isEqualTo(User.REDACTED_PASSWORD)
    );

    var recordedRequest = RecordedRequests.takeRequiredRequest(server);

    assertThatRecordedRequest(recordedRequest)
            .isPOST()
            .hasHeader("Accept", "application/json")
            .hasPath("/users")
            .hasBody(JSON_HELPER.toJson(newUser));
            
    RecordedRequests.assertNoMoreRequests(server);
}

This test does the following:

  1. Create a sample User entity
  2. Set up the response that the MockWebServer should return
  3. Call the create method on the "User API" client
  4. Make some assertions on the returned User object
  5. Get the recorded request from MockWebServer
  6. Check the request
  7. Verify that there are no more requests

To check the request, we verify that the request was a POST to /users, that it contains the required Accept header, and that it has the expected body. If the API is using JSON, then instead of doing the Object-to-JSON conversion manually, you can use hasJsonBodyWithEntity:

assertThatRecordedRequest(recordedRequest)
        .isPOST()
        .hasHeader("Accept", "application/json")
        .hasPath("/users")
        .hasJsonBodyWithEntity(newUser);

This will use a default kiwi JsonHelper instance. If you need control over the JSON serializaiton, you can use one of the overloaded hasJsonBodyWithEntity methods which accept either JsonHelper or a Jackson ObjectMapper. For example:

ObjectMapper mapper = customObjectMapper();

assertThatRecordedRequest(recordedRequest)
        .isPOST()
        .hasHeader("Accept", "application/json")
        .hasPath("/users")
        .hasJsonBodyWithEntity(newUser, mapper);

There are various other methods in RecordedRequestAssertions as well, for example methods to check the TLS version or whether there is a failure, perhaps because the inbound request was truncated. But the assertions in the above examples handle most of the use cases I've needed when writing HTTP client tests.

Wrapping Up

The kiwi-test library contains test utilities for making HTTP client testing with MockWebServer just a bit less tedious, with a little less boilerplate, and provides AssertJ-style fluent assertions for RecordedRequest. You can use these utilities to write cleaner and less "boilerplate-y" tests.

Testing HTTP Client Code with MockWebServer

Posted on January 15, 2025 by Scott Leberknight

When testing HTTP client code, it can be challenging to verify your application's behavior. For example, if you have an HTTP client that makes calls to some third-party API, or even to another service that you control, you want to make sure that you are sending the correct requests and handling the responses properly. There are various libraries available to help, and many times the library or framework you're using provides some kind of test support.

For example, I've used Dropwizard to create REST-based web services for a number of years. Dropwizard uses Jersey, which is the reference implementation of Jakarta RESTful Web Services (formerly known as JAX-RS). Dropwizard provides a way to test HTTP client implementations by creating a resource within your test that acts as a "test double" of the real server you are trying to simulate. When the test executes, a real HTTP server is started that can respond to real HTTP requests. No mocking, which is important since mocks can't easily simulate all the various things that can happen with HTTP requests.

Suppose you have an HTTP client that uses Jersey Client to call a "Math API". For now, you only care about adding two numbers, so your client looks like:

public class MathApiClient {

    private final Client client;
    private final URI baseUri;

    public MathApiClient(Client client, URI baseUri) {
        this.client = client;
        this.baseUri = baseUri;
    }

    public int add(int a, int b) {
        var response = client.target(baseUri)
                .path("/math/add/{a}/{b}")
                .resolveTemplate("a", a)
                .resolveTemplate("b", b)
                .request()
                .get();

        return response.readEntity(Integer.class);
    }
}

You want to design the client for easy testing, so the constructor accepts a Jersey Client and a URI, which lets you easily change the target server location. That's important, since you need to be able to provide the URI of the test server.

Here's an example of a Math API test class using Dropwizard's integration testing support:

@ExtendWith(DropwizardExtensionsSupport.class)
class DropwizardMathApiClientTest {

    @Path("/math")
    public static class MathStubResource {
        @GET
        @Path("/add/{a}/{b}")
        @Produces(MediaType.TEXT_PLAIN)
        public Response add(@PathParam("a") int a, @PathParam("b") int b) {
            var answer = a + b;
            return Response.ok(answer).build();
        }
    }

    private static final DropwizardClientExtension CLIENT_EXTENSION =
            new DropwizardClientExtension(new MathStubResource());

    private MathApiClient mathClient;
    private Client client;

    @BeforeEach
    void setUp() {
        client = ClientBuilder.newBuilder()
                .connectTimeout(500, TimeUnit.MILLISECONDS)
                .readTimeout(500, TimeUnit.MILLISECONDS)
                .build();
        var baseUri = CLIENT_EXTENSION.baseUri();
        mathClient = new MathApiClient(client, baseUri);
    }

    @AfterEach
    void tearDown() {
        client.close();
    }

    @Test
    void shouldAdd() {
        assertThat(mathClient.add(40, 2)).isEqualTo(42);
    }
}

In this code, it's the DropwizardClientExtension that provides all the real HTTP server functionality. You provide it the stub resource (a new MathStubResource instance) and it takes care of starting a real application that responds to HTTP requests and responds as you defined in the stub resource. Then you write tests that use the MathApiClient, make assertions as you normally would, and so on.

This works great, but there are some downsides. First, there is no way to (easily) verify the HTTP requests that the HTTP client made. The client makes the HTTP request and handles the response, but unless it provides some way to access the requests it has made, there's not really any way to verify this. You can add code into the stub resource to capture the requests, and provide a way for test code to access them, but that adds complexity to the stub resource.

Second, while testing the "happy path" is straightforward, things quickly become more difficult if you want to test errors, invalid input, and other "not happy path" scenarios. For example, let's say you want to test how your client responds when it receives an error response such as a 400 Bad Request or 500 Internal Server Error. How can you do this? One way is "magic input" where the server responds with a 400 when you provide one set of input (e.g., whenever a is 84) and a 500 when you provide a different input (e.g., whenever a is 142). Depending on the number of error cases you want to test, the stub resource code can quickly get complicated with conditionals. Another way is to use some kind of "flag" field inside the test stub resource class, where each test can "record" the response it wants. But this starts to become a "mini-framework" as you need more and more features.

Something else you can do is to create separate tests with different stub resources for different scenarios. But again, this can get out of control quickly if your HTTP client has a lot of methods and you want to test each one thoroughly.

Despite these shortcomings, you can still write good HTTP tests using what Dropwizard (and other similar libraries) provides. I've used the Dropwizard test support for the vast majority of HTTP client testing over the past few years. But I've recently come across the excellent MockWebServer from OkHttp. Basically, it is like a combination of a real HTTP server to test against and a mocking library such as Mockito.

To test HTTP clients using MockWebServer, you:

  1. Record the responses you want to receive
  2. Run your HTTP client code
  3. Make assertions about the result from the client (if any)
  4. Verify the client made the expected requests

This is very similar to using mocking like in Mockito, except that MockWebServer lets you test against the full HTTP/HTTPS request/response lifecycle in a realistic manner. So, rewriting the above test to use MockWebServer looks like:

class OkHttpMathApiClientTest {

    private MathApiClient mathClient;
    private Client client;
    private MockWebServer server;

    @BeforeEach
    void setUp() throws URISyntaxException {
        client = ClientBuilder.newBuilder()
                .connectTimeout(500, TimeUnit.MILLISECONDS)
                .readTimeout(500, TimeUnit.MILLISECONDS)
                .build();

        server = new MockWebServer();
        var baseUri = server.url("/").uri();

        mathClient = new MathApiClient(client, baseUri);
    }

    @AfterEach
    void tearDown() throws IOException {
        client.close();
        server.close();
    }

    @Test
    void shouldAdd() throws InterruptedException {
        server.enqueue(new MockResponse()
                .setResponseCode(200)
                .setHeader(HttpHeaders.CONTENT_TYPE, "text/plain")
                .setBody("42"));

        assertThat(mathClient.add(40, 2)).isEqualTo(42);

        var recordedRequest = server.takeRequest(1, TimeUnit.SECONDS);
        assertThat(recordedRequest).isNotNull();

        assertAll(
                () -> assertThat(recordedRequest.getMethod()).isEqualTo("GET"),
                () -> assertThat(recordedRequest.getPath()).isEqualTo("/math/add/40/2"),
                () -> assertThat(recordedRequest.getBodySize()).isZero()
        );
    }
}

In this test, we first record the response (or responses) we want to receive by calling enqueue with a MockResponse. Don't let the "Mock" in the name fool you, though, since this just tells MockWebServer the response you want. It will take care of returning a real HTTP response from a real HTTP server. The next line in the test is the same as in the Dropwizard example above, where we call the HTTP client and assert the result. But after that, MockWebServer lets you get the requests that the client code made using takeRequest, so you can verify that it sent exactly what it should have, with the expected path, query parameters, headers, body, etc.

One advantage of using MockWebServer is that it is really easy to record different responses and test how your client responds. For example, suppose the Math API returns a 400 response if you provide two numbers that add up to a number higher than the maximum value of a Java int, or a 500 response if there is a server error. Here are a few tests for those situations:

@Test
void shouldThrowIllegalArgumentException_ForInvalidInput() throws InterruptedException {
    server.enqueue(new MockResponse()
            .setResponseCode(400)
            .setHeader(HttpHeaders.CONTENT_TYPE, "text/plain")
            .setBody("overflow"));

    assertThatIllegalArgumentException()
            .isThrownBy(() -> mathClient.add(Integer.MAX_VALUE, 1))
            .withMessage("Invalid arguments: overflow");

    var recordedRequest = server.takeRequest(1, TimeUnit.SECONDS);
    assertThat(recordedRequest).isNotNull();

    assertAll(
            () -> assertThat(recordedRequest.getMethod()).isEqualTo("GET"),
            () -> assertThat(recordedRequest.getPath()).isEqualTo("/math/add/%d/1", Integer.MAX_VALUE)
    );
}

@Test
void shouldThrowIllegalStateException_ForServerError() throws InterruptedException {
    server.enqueue(new MockResponse()
            .setResponseCode(500)
            .setHeader(HttpHeaders.CONTENT_TYPE, "text/plain")
            .setBody("Server error: can't add right now"));

    assertThatIllegalStateException()
            .isThrownBy(() -> mathClient.add(2, 2))
            .withMessage("Unknown error: Server error: can't add right now");

    var recordedRequest = server.takeRequest(1, TimeUnit.SECONDS);
    assertThat(recordedRequest).isNotNull();

    assertAll(
            () -> assertThat(recordedRequest.getMethod()).isEqualTo("GET"),
            () -> assertThat(recordedRequest.getPath()).isEqualTo("/math/add/2/2", Integer.MAX_VALUE)
    );
}

Each test defines the response(s) that the MockWebServer should sent it. This makes it possible to create clean, self-contained test code that is easy to understand and change.

To make these tests pass, we should update the original implementation with some error handling code:

public int add(int a, int b) {
    var response = client.target(baseUri)
            .path("/math/add/{a}/{b}")
            .resolveTemplate("a", a)
            .resolveTemplate("b", b)
            .request()
            .get();

    if (successful(response)) {
        return response.readEntity(Integer.class);
    } else if (clientError(response)) {
        throw new IllegalArgumentException("Invalid arguments: " + response.readEntity(String.class));
    }

    throw new IllegalStateException("Unknown error: " + response.readEntity(String.class));
}

The code examples (adding two numbers) I've used are simple. In "real life" you are probably calling more complicated and expansive APIs, and need to test various success and failure scenarios. To recap, some of the advantages of using MockWebServer in your HTTP client tests are:

  • You can record different responses for each test (similar to setting up mock objects, e.g., Mockito)
  • You can avoid having to implement "stub" resources that are a "shadow API" of the remote API
  • Avoiding complexity in "stub" resources when adding logic to provide different responses based on inputs or other signals
  • You can verify the requests that were made, like how you verify method calls with mocking (e.g., Mockito)

There are other things you can do with MockWebServer, for example you can throttle responses to simulate a slow network to test timeout and retry behavior. You can also test with and without HTTPS, requiring client certificates, and customizing the supported protocols. These are all things that can be done in custom code, but it's much nicer when it comes out of the box.

To sum up, MockWebServer makes it simple to write tests for HTTP client code, allowing you to test the "happy path" and various failure scenarios, and provides support for more advanced testing situations such as when requiring client certificate authentication or simulating network slowness.

This is the final (and way, way overdue) article in a series of blogs describing how you can effectively use Hibernate validators. The fifth article described how to bypass Hibernate validation in specific use cases, for example if you need to save a "draft" object that should not be validated yet. In this article I'll describe how the Hibernate Validator can be integrated into web applications so that validation errors propagate seamlessly from the data access code back up through the web tier and to the end user as nicely formatted error messages. For this article I'm using Spring MVC, but the basic concept should be applicable no matter which of the 100 billion Java web frameworks you are using.

The basic concept is this: When a user submits a form, you first want to bind the form values to your domain object (making sure of course that you only allow user-editable fields to be bound to the object). You then want to validate the domain object with the updated values. Finally, you want the Hibernate Validator validation errors translated into your web framework's native error validation mechanism so it can inform the user of the errors and do things like display the errors to the user next to the problematic fields. Stated more succinctly, the following steps must occur: submit form, data binding to domain object, validate domain object using Hibernate Validator, translate Hibernate Validator errors to web framework errors, re-display form with nicely formatted error messages to user for correction.

The only piece we don't have is the translation of Hibernate Validator errors into web framework errors. Since I'm using Spring MVC in this case, I'll need to take the Hibernate Validator errors and translate them into a Spring Errors object. For this I can use Spring MVC's Validator interface to implement the error translation in a generic fashion. For this blog the implementation is going to be simple and naive, and won't take into account things like nested properties or internationalization because I want to keep things relatively simple.

So, let's look at the HibernateAnnotationSpringValidator class which is responsible for validating any of our domain objects (which are all assumed to ultimately extend from a custom BaseEntity class for this example) and then translating the Hibernate Validator InvalidValue objects into Spring MVC Errors.

package com.nearinfinity.common.spring.validation.hibernate;

// imports...

public class HibernateAnnotationSpringValidator implements Validator {

  private Map validatorCache = new HashMap();

  public boolean supports(Class clazz) {
    return BaseEntity.class.isAssignableFrom(clazz);
  }

  @SuppressWarnings(CompilerWarnings.UNCHECKED)
  public void validate(Object value, Errors errors) {
    Class type = value.getClass();
    ClassValidator validator = validatorCache.get(type);
    if (validator == null) {
      validator = new ClassValidator(type);
      validatorCache.put(type, validator);
    }
    InvalidValue[] invalidValues = validator.getInvalidValues(value);
    translateToSpringValidationErrors(invalidValues, errors);
  }

  private void translateToSpringValidationErrors(InvalidValue[] invalidValues, Errors errors) {
    for (InvalidValue invalidValue : invalidValues) {
      String propertyName = invalidValue.getPropertyName();
      if (propertyName == null) {
        errors.reject(null, invalidValue.getMessage());
      }
      else {
        String titleCasedPropertyName = StringUtils.camelCaseToTitleCase(propertyName);
        String errorMessage = titleCasedPropertyName + " " + invalidValue.getMessage();
        errors.rejectValue(invalidValue.getPropertyPath(), null, errorMessage);
      }
    }
  }

}

The most important things in the above code are the validate and translateToSpringValidationErrors methods. As expected, validate expects a domain object that extends from BaseEntity and uses Hibernate Validator to validate it. This validator caches Hibernate ClassValidator instances and so one instance of HibernateAnnotationSpringValidator could be used in all of your Spring MVC controllers if desired. It then validates the object and gets back an array of InvalidValue objects from Hibernate Validator. Finally, it calls translateToSpringValidationErrors.

The translateToSpringValidationErrors method iterates through the InvalidValues and transforms them into Spring MVC errors. This implementation is very simplistic and not i18n-ready as it uses the domain object property names to create the error messages using a utility method I wrote called camelCaseToTitleCase. For example, if the property name is "firstName" then the "titleCasedPropertyName" is simply "First Name." So, if the InvalidValue's getMessage() method returns "is required" then the error message would be "First Name is required." Obviously for production use you'll want to make something more robust than this and support i18n if you need to.

Now you have all the pieces and simply need to plug-in the HibernateAnnotationSpringValidator and use as you would any Spring Validator which is documented extensively in the Spring docs and lots of books, blogs, twitters, etc. Obviously this example only works with Spring MVC. To make it work for the web framework you are using, you'll need to hook into your framework's validation mechanism and do something similar. Again, the two most important things are (1) using Hibernate Validator to validate an object (doesn't even need to be a domain object for that matter since Hibernate Validator can be used on its own independent of Hibernate even) and (2) translating the Hibernate validation errors into your web framework's native errors. That's it!