Rust has excellent tools for faking HTTP inside one process: wiremock, httpmock and mockito all spin up a real server on loopback and let you stub responses with expressive matchers. For fast unit tests they're usually the right choice โ this guide's comparison table says so plainly.
But an in-process mock can't help when you need a URL that outlives one cargo test run:
The boring fix is a hosted mock API. Every snippet below was compiled and run verbatim against the live demo project before publishing (rustc 1.97, reqwest 0.12.28, tokio 1.53) โ you can paste and run them too.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"rust-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 snippets below use the public demo project so they run with zero setup. Cargo.toml for everything on this page:
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest-middleware = "0.4" # ยง5 only
reqwest-retry = "0.7" # ยง5 only
use serde_json::Value;
const BASE: &str = "https://mockbird.mockbird.workers.dev/m/demo";
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let resp = reqwest::get(format!(
"{BASE}/products?page=1&limit=5&sortBy=price&order=desc"
))
.await?;
let total = resp
.headers()
.get("x-total-count") // real header, not a fixture
.and_then(|v| v.to_str().ok())
.unwrap_or("?")
.to_owned();
let products: Vec<Value> = resp.json().await?;
println!("{total} products total; page of {}", products.len());
println!("first: {} {}", products[0]["name"], products[0]["price"]);
Ok(())
}
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.
use serde::{Deserialize, Serialize};
const BASE: &str = "https://mockbird.mockbird.workers.dev/m/demo";
#[derive(Serialize, Deserialize, Debug)]
struct Product {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<i64>,
name: String,
price: f64,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{BASE}/products"))
.json(&Product { id: None, name: "Test Widget".into(), price: 9.99 })
.send()
.await?;
let status = resp.status(); // 201
let created: Product = resp.json().await?; // the stored record
let id = created.id.unwrap();
println!("created: {status} {id}");
let back: Product = client
.get(format!("{BASE}/products/{id}"))
.send()
.await?
.json()
.await?;
println!("persisted: {}", back.name == "Test Widget"); // it persisted
let del = client.delete(format!("{BASE}/products/{id}")).send().await?;
println!("deleted: {}", del.status()); // 404s afterwards
Ok(())
}
When we ran it: created: 201 Created 31 โ persisted: true โ deleted: 200 OK. 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.
use std::time::Duration;
const BASE: &str = "https://mockbird.mockbird.workers.dev/m/demo";
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
// any status code on demand
let resp = reqwest::get(format!("{BASE}/products?mock_status=503")).await?;
println!("forced status: {}", resp.status()); // 503
// a genuinely slow response vs your client timeout
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(1))
.build()?;
let err = client
.get(format!("{BASE}/products?mock_delay=3000"))
.send()
.await
.unwrap_err();
println!("timeout branch actually ran: {}", err.is_timeout()); // true
Ok(())
}
That second one matters: a stub answers in microseconds, so the code path where reqwest::Error::is_timeout() returns true after a real deadline is rarely exercised. Here the server genuinely holds the response for 3 seconds and the 1-second timeout really fires โ when we ran it, both lines printed exactly the comments above.
Retry code is subtle (which statuses, backoff curve, giving up). Wrap your client in reqwest-retry and point it at an endpoint where ~half the requests fail with a random 5xx/429 โ then watch it earn its keep:
use reqwest_middleware::ClientBuilder;
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
#[tokio::main]
async fn main() {
// retries 429 + 5xx with exponential backoff, up to 5 times
let policy = ExponentialBackoff::builder().build_with_max_retries(5);
let client = ClientBuilder::new(reqwest::Client::new())
.with(RetryTransientMiddleware::new_with_policy(policy))
.build();
for i in 0..5 {
let resp = client
.get("https://mockbird.mockbird.workers.dev/m/demo/products?mock_chaos=0.5&limit=1")
.send()
.await;
match resp {
Ok(r) => println!("{i} {}", r.status()), // all 200 โ retries absorbed the failures
Err(e) => println!("{i} gave up: {e}"),
}
}
}
When we ran it: five 200 OKs โ the middleware silently absorbed every injected failure. 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.
use serde_json::Value;
const BASE: &str = "https://mockbird.mockbird.workers.dev/m/demo";
async fn all_records(
client: &reqwest::Client,
resource: &str,
limit: usize,
) -> Result<Vec<Value>, Box<dyn std::error::Error>> {
let mut out: Vec<Value> = Vec::new();
let mut total = usize::MAX;
let mut page = 1;
while out.len() < total {
let resp = client
.get(format!("{BASE}/{resource}?page={page}&limit={limit}"))
.send()
.await?
.error_for_status()?;
total = resp
.headers()
.get("x-total-count")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.ok_or("missing X-Total-Count")?;
let batch: Vec<Value> = resp.json().await?;
if batch.is_empty() {
break;
}
out.extend(batch);
page += 1;
}
Ok(out)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let all = all_records(&client, "products", 10).await?;
println!("{}", all.len()); // 30 โ 3 real round-trips of 10
Ok(())
}
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 fixtures, no ordering constraints, and parallel test threads are safe because nobody mutates anything. tests/snapshot.rs:
use serde_json::Value;
const BASE: &str = "https://mockbird.mockbird.workers.dev/m/abc123";
/// GET a path pinned to a named snapshot; returns (X-Total-Count, records).
async fn get(path: &str, snapshot: &str) -> (String, Vec<Value>) {
let resp = reqwest::Client::new()
.get(format!("{BASE}{path}"))
.header("X-Mockbird-Snapshot", snapshot)
.send()
.await
.expect("request failed");
assert!(resp.status().is_success(), "HTTP {}", resp.status());
let total = resp
.headers()
.get("x-total-count")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
let records: Vec<Value> = resp.json().await.expect("bad json");
(total, records)
}
#[tokio::test]
async fn user_list_baseline() {
let (_, users) = get("/users", "baseline").await; // always 5, forever
assert_eq!(users.len(), 5);
}
#[tokio::test]
async fn empty_state() {
let (total, users) = get("/users", "empty").await;
assert_eq!(users.len(), 0);
assert_eq!(total, "0");
}
We ran exactly this file before publishing: test result: ok. 2 passed; 0 failed, in parallel, 0.20s. 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's Rust generator and you get a full typed client crate (reqwest-based, serde models) for the exact shapes your mock serves:
curl -o openapi.json https://mockbird.mockbird.workers.dev/m/demo/openapi.json
openapi-generator-cli generate -g rust -i openapi.json -o demoapi \
--additional-properties=packageName=demoapi
cd demoapi && cargo check
We ran that too (openapi-generator 7.10.0): it generated serde models for products, orders, customers and reviews plus a reqwest API module โ and cargo check passes clean. Contract-first without writing the contract by hand; when the real backend ships, point the generated client's base path 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 crates are genuinely good โ and unlike JavaScript interceptors, they bind a real port on loopback, so your client does real HTTP. For pure unit tests they're usually the better choice. The split:
| wiremock (crate) | httpmock | mockito | Mockbird | |
|---|---|---|---|---|
| What it is | Async loopback mock server with matchers | Loopback mock server, sync + async, standalone mode | Loopback mock server, simple API | Hosted mock API |
| Works offline / zero latency | โ (loopback) | โ | โ | โ (real network) |
| Stubs you must write & maintain | Every mount, by hand | Every stub, by hand | Every stub, by hand | None โ CRUD, filters, search, pagination built in |
| Reachable by teammates, frontend, CI, other services | โ one process | Partial (standalone binary, you host it) | โ one process | โ one https URL |
| Real timeouts / slow responses / rate limits | โ set_delay, loopback-fast otherwise | โ delays | Partial | โ mock_delay / mock_chaos / mock_ratelimit |
| State persists across processes/runs | โ | โ | โ | โ (+ snapshots) |
| Verify "was this called?" | โ expectations โ its superpower | โ assertions | โ expect() | Partial: request inspector |
| Free tier | free (crate) | free (crate) | free (crate) | free: 20 projects, 10k req/project/day |
Use both: keep wiremock or httpmock for millisecond-fast unit tests with call verification, and point integration tests, scripts, teaching materials and not-yet-built-backend work at a hosted URL.