โ† All guides

Mock a REST API for Rust โ€” reqwest, retries and cargo test against a real URL

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.

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":"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

2. reqwest quickstart โ€” real pagination headers

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.

3. Writes are real โ€” and deserialize into your structs

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.

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

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.

5. Exercise real retry logic with injected chaos

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.

6. A pagination helper you can actually test

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.

7. cargo test: 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 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.

8. Ship day: generate a typed client crate 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'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.

9. Honest comparison

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)httpmockmockitoMockbird
What it isAsync loopback mock server with matchersLoopback mock server, sync + async, standalone modeLoopback mock server, simple APIHosted mock API
Works offline / zero latencyโœ” (loopback)โœ”โœ”โœ– (real network)
Stubs you must write & maintainEvery mount, by handEvery stub, by handEvery stub, by handNone โ€” CRUD, filters, search, pagination built in
Reachable by teammates, frontend, CI, other servicesโœ– one processPartial (standalone binary, you host it)โœ– one processโœ” one https URL
Real timeouts / slow responses / rate limitsโœ” set_delay, loopback-fast otherwiseโœ” delaysPartialโœ” 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 tierfree (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.

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, Java, Ruby, PHP, C#/.NET or Elixir 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.