Testing Java HTTP Clients with Unirest and MockServer

Testing a Java HTTP client becomes misleading when unit tests stop at the service object and never exercise JSON encoding, headers, status handling, or timeouts. Unirest provides a compact HTTP API, while MockServer gives tests a controllable endpoint that can return precise responses and record what the client sent.

Keeping that boundary in a test makes failures easier to diagnose and removes unreliable calls to shared environments. The aim is not to reproduce the internet; it is to model the small part of an HTTP contract your code depends on, then repeat it consistently on a developer laptop and in a delivery pipeline.

Why isolate the HTTP boundary

Unirest is an HTTP client rather than a test framework. A client test should create a request, send it to a known address, and make assertions about the exchange. MockServer occupies the other side of that exchange, matching methods, paths, query parameters, headers, and bodies before returning a configured status and payload.

This control is valuable for edge cases that are awkward to provoke in a real service: malformed JSON, a 429 response, an empty body, or a downstream timeout. It also keeps credentials out of the test process. For Australian teams, fixtures can use AUD, GST-inclusive totals, en-AU content, and a .com.au hostname without contacting a live partner.

Set up the test dependencies

Use the io.github.kstyrc:unirest artifact for Unirest Java; similarly named packages can otherwise pull the wrong client API. Add MockServer’s client and Netty runtime as test dependencies. Pin both versions in the build so a dependency update cannot silently alter matcher or assertion behaviour.

The Java client talks to an embedded Netty server, so no Docker daemon or external process is required. This keeps the test portable across developer machines, CI runners, and the different office networks common in Sydney, Melbourne, Brisbane, and Perth.

<properties>
    <unirest.version>3.14.5</unirest.version>
    <mockserver.version>5.15.0</mockserver.version>
</properties>
<dependencies>
    <dependency>
        <groupId>io.github.kstyrc</groupId>
        <artifactId>unirest</artifactId>
        <version>${unirest.version}</version>
    </dependency>
    <dependency>
        <groupId>org.mock-server</groupId>
        <artifactId>mockserver-client-java</artifactId>
        <version>${mockserver.version}</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mock-server</groupId>
        <artifactId>mockserver-netty</artifactId>
        <version>${mockserver.version}</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Make one deterministic client call

Start MockServer on an ephemeral port to prevent clashes between tests or parallel build workers. Register the expected request before calling Unirest, and keep the response fixture small enough that its purpose is obvious. A dynamic port also means the test never needs a fixed network route.

MockServerClient server = new MockServerClient("localhost", 0).start();
int port = server.getPort();
String baseUrl = "http://localhost:" + port;

server.when(
    HttpRequest.request()
        .withMethod("GET")
        .withPath("/v1/customers/cus_42")
        .withHeader("Accept", "application/json")
).respond(
    HttpResponse.response()
        .withStatusCode(200)
        .withHeader("Content-Type", "application/json")
        .withBody(
            "{\"id\":\"cus_42\",\"name\":\"Priya Nair\",\"currency\":\"AUD\"}"
        )
);

HttpResponse<String> response = Unirest.get(
        baseUrl + "/v1/customers/cus_42")
    .header("Accept", "application/json")
    .asString();

assertEquals(200, response.getStatus());
assertTrue(response.getBody().contains("\"currency\":\"AUD\""));

Stop the server in test teardown, even when an assertion fails. In JUnit, an @AfterEach method gives every test a clean endpoint, while a try/finally block offers the same protection in a plain Java program.

Exercise errors, retries, and sequences

Error-path tests should assert both the response your client sees and the number of requests it makes. MockServer can return a failure once and then a success, allowing a test to model a short retry window without a sleep or a live flaky endpoint.

HttpRequest retry = HttpRequest.request()
    .withMethod("GET")
    .withPath("/v1/risk");

server.when(retry).respond(
    HttpResponse.response().withStatusCode(503),
    Times.exactly(1)
);
server.when(retry).respond(
    HttpResponse.response()
        .withStatusCode(200)
        .withBody("{\"status\":\"accepted\"}")
);

HttpResponse<String> first =
    Unirest.get(baseUrl + "/v1/risk").asString();
HttpResponse<String> second =
    Unirest.get(baseUrl + "/v1/risk").asString();

assertEquals(503, first.getStatus());
assertEquals(200, second.getStatus());

For an application-level retry, configure the policy in the wrapper that owns Unirest and verify two matching requests. Keep timeout testing separate from sequence testing: use a bounded client timeout and a controlled server delay, then assert an HttpTimeoutException or the wrapper’s documented fallback rather than depending on an arbitrary race.

Verify what the client sent

A successful status code does not prove that the request payload was correct. MockServer’s request expectations can match JSON fields, and its verification API can confirm that an endpoint received exactly one call. These checks catch changes to field names, amounts, content types, and idempotency behaviour.

String payload =
    "{\"customerId\":\"cus_42\",\"currency\":\"AUD\",\"total\":125.40}";

server.when(
    HttpRequest.request()
        .withMethod("POST")
        .withPath("/v1/orders")
        .withHeader("Content-Type", "application/json")
        .withBody(json(payload, MatchType.ONLY_MATCHING_FIELDS))
).respond(HttpResponse.response().withStatusCode(201));

HttpResponse<String> created = Unirest.post(baseUrl + "/v1/orders")
    .header("Content-Type", "application/json")
    .body(payload)
    .asString();

assertEquals(201, created.getStatus());

server.verify(
    HttpRequest.request()
        .withMethod("POST")
        .withPath("/v1/orders"),
    VerificationTimes.exactly(1)
);

Match only fields that define the contract. A volatile request ID or current timestamp should be captured and checked with a regular expression or a purpose-built matcher, not hard-coded. If retries are valid, express the expected count precisely so the test detects both missing calls and accidental duplicates.

Keep the suite reliable in Australian teams

Time handling is a common source of disagreement across Australia. Sydney and Melbourne move between UTC+10 and UTC+11 during daylight saving, while Perth remains on UTC+8. Send and assert timestamps as Instant or OffsetDateTime, include an explicit offset in fixtures, and avoid deriving expected values from the build machine’s default time zone.

Network conditions also differ across office broadband, home connections, VPNs, and CI runners. A local MockServer instance removes that variation from the client contract, while a separate smoke test can check a sandbox when credentials and data governance permit it. Synthetic records are the safer default, particularly for services operating under the Privacy Act 1988 and the Australian Privacy Principles.

Locale deserves the same care. A response containing “no worries” is a harmless UTF-8 check, while amounts can include AUD with GST represented explicitly. Consumer-facing Australian services often expose prices with GST included, so keep business assertions independent of display formatting and add a focused test for currency symbol placement or decimal separators when that behaviour belongs to the client.

Compare approaches and establish a baseline

Unirest remains the same across the options; the difference is the endpoint used during the test and the degree of contract control it provides.

Approach Best fit Main strength Main constraint
Unirest with MockServer Request matching, scenarios, verification Broad HTTP mocking and clear expectations More setup than a minimal queue server
Unirest with MockWebServer Small HTTP integration tests Lightweight and fast Basic response handling and less expressive verification
Unirest with WireMock Teams already using its stubbing model Strong stubs, scenarios, and reporting Another DSL and runtime to standardise
Unirest with a real sandbox Contract and dependency smoke tests Exercises real networking and auth Secrets, state, cost, and rate limits

MockServer is a good default when the client needs meaningful request matching, ordered failures, or verification of outgoing calls. Keep MockWebServer for a narrow queue-based interaction, and reserve real sandbox calls for a small layer that checks genuine DNS, TLS, authentication, and service behaviour.

For the client in this article, the concrete next step is to add one Maven test that starts MockServer on a dynamic port, asserts a successful AUD response, verifies the outgoing request once, and stops the server in teardown.