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.
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.
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.
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.
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.
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.
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.
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.
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.
The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. The split:
| WireMock (embedded) | MockWebServer | Mockito stubs | Mockbird | |
|---|---|---|---|---|
| What it is | Full mock server in your JVM, rich matching DSL | Real loopback server (OkHttp project) | Mocks your own client interface โ no HTTP at all | Hosted mock API |
| Works offline / zero latency | โ | โ (loopback) | โ | โ (real network) |
| Stubs you must write & maintain | Every stub, in their DSL | Every enqueued response, by hand | Every when(...), by hand | None โ 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 injection | Partial โ 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 tier | free (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.)