โ† All guides

Mock a REST API for Java โ€” HttpClient, Resilience4j retries and JUnit against a real URL

Java has excellent tools for faking HTTP inside one JVM: WireMock embedded in a JUnit test is the community standard, OkHttp's MockWebServer spins up a real loopback server, and Mockito can stub your own client interface away entirely. For fast unit tests they're usually the right choice โ€” this guide's comparison table says so plainly.

But an in-process fake can't help when you need a URL that outlives one JUnit run:

The boring fix is a hosted mock API. Every snippet below was run verbatim against the live demo project before publishing (Java 17, Jackson 2.17, Resilience4j 2.2) โ€” you can paste and run them too. Plain java.net.http.HttpClient from the JDK; the only dependencies are Jackson for JSON, and ยง5's retry library.

1. Get an API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"java-demo"}'
# โ†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}

curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/resources \
  -H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
  -d '{"name":"users","template":"users","seed":50}'

That's 50 realistic users (names, emails, avatars, cities) with full CRUD, filtering, sorting, search and pagination. The create response also includes a dashboard link that opens the project in a web UI โ€” handy when you're done with the terminal. The snippets below use the public demo project so they run with zero setup.

2. HttpClient quickstart โ€” real pagination headers

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.util.List;
import java.util.Map;

public class S2Quickstart {
    static final String BASE = "https://mockbird.mockbird.workers.dev/m/demo";

    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest req = HttpRequest.newBuilder(
                URI.create(BASE + "/products?page=1&limit=5&sortBy=price&order=desc")).build();
        HttpResponse<String> r = client.send(req, HttpResponse.BodyHandlers.ofString());

        List<Map<String, Object>> products = new ObjectMapper()
                .readValue(r.body(), new com.fasterxml.jackson.core.type.TypeReference<>() {});
        String total = r.headers().firstValue("X-Total-Count").orElse("?"); // real header
        System.out.printf("%s products total; page of %d%n", total, products.size());
        System.out.println("first: " + products.get(0).get("name") + " " + products.get(0).get("price"));
    }
}

Run it with nothing but the JDK and Jackson on the classpath โ€” no build tool required: java -cp "lib/*" S2Quickstart.java (single-file source launch, Java 11+). Output when we ran it: 30 products total; page of 5. Exact-match filters (?category=tools), range operators (?price_gte=100), substring match (?name_like=river) and full-text ?q= all compose โ€” see the parameter reference.

3. Writes are real โ€” and bind to your records

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;

public class S3Writes {
    static final String BASE = "https://mockbird.mockbird.workers.dev/m/demo";

    @JsonIgnoreProperties(ignoreUnknown = true)
    record Product(Integer id, String name, Double price) {}

    public static void main(String[] args) throws Exception {
        var client = HttpClient.newHttpClient();
        var mapper = new ObjectMapper();

        String body = mapper.writeValueAsString(new Product(null, "Test Widget", 9.99));
        var post = HttpRequest.newBuilder(URI.create(BASE + "/products"))
                .header("content-type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body)).build();
        var r = client.send(post, HttpResponse.BodyHandlers.ofString());
        Product created = mapper.readValue(r.body(), Product.class); // 201 + the stored record
        System.out.println("created: " + r.statusCode() + " " + created.id());

        var r2 = client.send(HttpRequest.newBuilder(URI.create(BASE + "/products/" + created.id())).build(),
                HttpResponse.BodyHandlers.ofString());
        Product back = mapper.readValue(r2.body(), Product.class);
        System.out.println("persisted: " + back.name().equals("Test Widget")); // it persisted

        var del = HttpRequest.newBuilder(URI.create(BASE + "/products/" + created.id())).DELETE().build();
        System.out.println("deleted: " + client.send(del, HttpResponse.BodyHandlers.discarding()).statusCode());
    }
}

When we ran it: created: 201 31 โ†’ persisted: true โ†’ deleted: 200. Jackson binds straight into a Java record, and PUT/PATCH/DELETE behave like a real backend. (This is the part JSONPlaceholder, FakeStoreAPI and DummyJSON fake โ€” details with receipts in their respective guides.) Add ?mock_validate=1 to get real 422s with per-field errors when the body doesn't match the schema.

4. Sad paths: force any status, hit a real client timeout

import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public class S4SadPaths {
    static final String BASE = "https://mockbird.mockbird.workers.dev/m/demo";

    public static void main(String[] args) throws Exception {
        var client = HttpClient.newHttpClient();

        // any status code on demand
        var r = client.send(HttpRequest.newBuilder(URI.create(BASE + "/products?mock_status=503")).build(),
                HttpResponse.BodyHandlers.ofString());
        System.out.println("forced status: " + r.statusCode()); // 503

        // a genuinely slow response vs your request timeout
        var slow = HttpRequest.newBuilder(URI.create(BASE + "/products?mock_delay=3000"))
                .timeout(Duration.ofSeconds(1)).build();
        try {
            client.send(slow, HttpResponse.BodyHandlers.ofString());
        } catch (HttpTimeoutException e) {
            System.out.println("timeout branch actually ran: " + e); // request timed out
        }
    }
}

That second one matters: a stub answers instantly, so your HttpTimeoutException handling is never truly exercised. Here the server really holds the response for 3 seconds and the request's 1-second deadline really fires โ€” when we ran it, the catch block printed the genuine java.net.http.HttpTimeoutException: request timed out.

5. Exercise real retry logic with injected chaos

Retry code is subtle (which statuses, backoff curve, giving up). Point Resilience4j at an endpoint where ~half the requests fail with a random 5xx/429 and watch it earn its keep:

import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryConfig;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public class S5Retry {
    public static void main(String[] args) throws Exception {
        var client = HttpClient.newHttpClient();
        var chaos = URI.create(
            "https://mockbird.mockbird.workers.dev/m/demo/products?mock_chaos=0.5&limit=1");

        RetryConfig config = RetryConfig.<HttpResponse<String>>custom()
                .maxAttempts(5)
                .waitDuration(Duration.ofMillis(300))
                .retryOnResult(r -> r.statusCode() == 429 || r.statusCode() >= 500)
                .build();
        Retry retry = Retry.of("mock-api", config);

        for (int i = 0; i < 5; i++) {
            HttpResponse<String> r = retry.executeCallable(() ->
                    client.send(HttpRequest.newBuilder(chaos).build(),
                                HttpResponse.BodyHandlers.ofString()));
            System.out.println(i + " " + r.statusCode()); // all 200 โ€” retries absorbed the failures
        }
        System.out.println("injected failures absorbed: "
                + retry.getMetrics().getNumberOfSuccessfulCallsWithRetryAttempt() + " calls needed a retry");
    }
}

When we ran it: five 200s, and Resilience4j's own metrics reported 2 calls needed a retry โ€” the injected failures were real, and the retries silently absorbed every one. mock_chaos=0.5 fails that fraction of requests with a random status from {500, 502, 503, 504, 429}; injected failures carry an x-mockbird-chaos: injected header, and chaos-failed writes are not applied โ€” so it's safe to point retrying write logic at it too. For deterministic 429 testing (fixed window, real Retry-After and x-ratelimit-* headers), use ?mock_ratelimit=N.

6. A pagination helper you can actually test

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class S6Paginate {
    static final String BASE = "https://mockbird.mockbird.workers.dev/m/demo";
    static final HttpClient CLIENT = HttpClient.newHttpClient();
    static final ObjectMapper MAPPER = new ObjectMapper();

    static List<Map<String, Object>> allRecords(String resource, int limit) throws Exception {
        var out = new ArrayList<Map<String, Object>>();
        int total = -1;
        for (int page = 1; total < 0 || out.size() < total; page++) {
            var r = CLIENT.send(HttpRequest.newBuilder(
                    URI.create("%s/%s?page=%d&limit=%d".formatted(BASE, resource, page, limit))).build(),
                    HttpResponse.BodyHandlers.ofString());
            if (r.statusCode() != 200) throw new RuntimeException("HTTP " + r.statusCode());
            total = Integer.parseInt(r.headers().firstValue("X-Total-Count").orElse("0"));
            out.addAll(MAPPER.readValue(r.body(), new TypeReference<List<Map<String, Object>>>() {}));
        }
        return out;
    }

    public static void main(String[] args) throws Exception {
        System.out.println(allRecords("products", 10).size()); // 30 โ€” 3 real round-trips of 10
    }
}

Three real round-trips of 10, driven by the server's X-Total-Count. There's also opaque cursor pagination (?cursor=) if the API you're mimicking paginates that way.

7. JUnit 5: pin each test to a frozen snapshot

Shared mutable test data is how suites go flaky. Mockbird lets you save named snapshots of a project's entire dataset, then answer any GET from a snapshot โ€” read-only, live data untouched โ€” by sending one header. Set the scenarios up once:

ADMIN='x-admin-key: YOUR_KEY'
P=https://mockbird.mockbird.workers.dev/api/projects/abc123

curl -X POST $P/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"baseline"}'
curl -X POST $P/resources/users -H "$ADMIN" -H 'content-type: application/json' -d '{"seed":0}'   # wipe
curl -X POST $P/snapshots -H "$ADMIN" -H 'content-type: application/json' -d '{"name":"empty"}'
curl -X POST $P/snapshots/baseline/restore -H "$ADMIN"    # put live data back

Then each test pins the scenario it wants. No @BeforeEach resets, no ordering constraints โ€” and JUnit's ExecutionMode.CONCURRENT is safe because nobody mutates anything:

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;

import java.net.URI;
import java.net.http.*;
import java.util.List;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Execution(ExecutionMode.CONCURRENT)
class SnapshotPinningTest {
    static final String BASE = "https://mockbird.mockbird.workers.dev/m/abc123";
    static final HttpClient CLIENT = HttpClient.newHttpClient();
    static final ObjectMapper MAPPER = new ObjectMapper();

    static HttpResponse<String> get(String path, String snapshot) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(BASE + path))
                .header("X-Mockbird-Snapshot", snapshot).build();
        return CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
    }

    static List<Map<String, Object>> records(HttpResponse<String> r) throws Exception {
        return MAPPER.readValue(r.body(), new TypeReference<>() {});
    }

    @Test
    void userListBaseline() throws Exception {
        var users = records(get("/users", "baseline")); // always 5, forever
        assertEquals(5, users.size());
    }

    @Test
    void emptyState() throws Exception {
        var r = get("/users", "empty");
        assertEquals(0, records(r).size());
        assertEquals("0", r.headers().firstValue("X-Total-Count").orElse(""));
    }
}

We ran exactly this file before publishing (JUnit 5 console launcher, parallel enabled): 2 tests successful, 0 failed. Want to try pinning with zero setup? The public demo ships with built-in empty and edge-cases snapshots (100-char names, unicode, price 0, HTML-ish strings):

curl 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty'
# โ†’ []

The same trick scales per-scenario โ€” edge-cases, bug-repro-1234 โ€” see the deterministic test data guide.

8. Ship day: generate a typed client from the same mock

Every project serves a live OpenAPI 3.0 document at /m/<project>/openapi.json that always matches the current schema. Feed it to openapi-generator with the native library and you get a client built on the same java.net.http.HttpClient used above:

curl -o openapi.json https://mockbird.mockbird.workers.dev/m/demo/openapi.json
java -jar openapi-generator-cli.jar generate -i openapi.json \
  -g java --library native -o client --additional-properties=useJakartaEe=true
cd client && mvn compile

We ran that too (openapi-generator 7.8.0): it generated 18 source files โ€” typed models and one API class per resource โ€” and mvn compile passes clean. Contract-first without writing the contract by hand; when the real backend ships, point the generated ApiClient's base URL at it. There are also TypeScript types and a Postman collection for the rest of the team, generated from the same schema.

9. Honest comparison

The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:

WireMock (embedded)MockWebServerMockito stubsMockbird
What it isFull mock server in your JVM, rich matching DSLReal loopback server (OkHttp project)Mocks your own client interface โ€” no HTTP at allHosted mock API
Works offline / zero latencyโœ”โœ” (loopback)โœ”โœ– (real network)
Stubs you must write & maintainEvery stub, in their DSLEvery enqueued response, by handEvery when(...), by handNone โ€” CRUD, filters, search, pagination built in
Reachable by teammates, frontend, CI, other servicesโœ– one JVM (Cloud version is $$$)โœ– one JVMโœ– one JVMโœ” one https URL
Real network timeouts / slow responses / rate limitsโœ” fixed delays, fault injectionPartial โ€” throttle APIโœ– nothing is realโœ” mock_delay / mock_chaos / mock_ratelimit
State persists across JVMs/runsโœ– (scenarios reset)โœ–โœ–โœ” (+ snapshots)
Verify "was this called?"โœ” its superpowerโœ” takeRequest()โœ” verify(...)Partial: request inspector
Free tierfree (library)free (library)free (library)free: 20 projects, 10k req/project/day

Use both: keep WireMock or MockWebServer for millisecond-fast unit tests with request verification, and point integration tests, scripts, teaching materials and not-yet-built-backend work at a hosted URL. (Evaluating WireMock Cloud specifically? We compared it honestly here.)

Also mocking for a frontend? The same project feeds React, Vue, Angular, Next.js, SvelteKit, React Native, Flutter and Android at once โ€” one dataset for the whole team. Python, Node, Go, Ruby, PHP, Rust, Elixir or C#/.NET service in the mix too? Same drill. Docs โ†’
โšก Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard โ€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.