โ† All guides

Mock a REST API for Elixir โ€” Req, ExUnit and real retries against a real URL

Elixir has excellent tools for faking HTTP inside one BEAM node: Bypass spins up a real Plug server on loopback, Mox swaps a behaviour-shaped client for a strict contract double, and ExVCR replays recorded cassettes. For fast unit tests they're usually the right choice โ€” this guide's comparison table says so plainly.

But an in-node mock can't help when you need a URL that outlives one mix test run:

The boring fix is a hosted mock API. Every snippet below was run verbatim against the live demo project before publishing (Elixir 1.16, Req 0.7.2) โ€” each one is a standalone .exs script (Mix.install fetches Req), so 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":"elixir-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.

2. Req quickstart โ€” real pagination headers

Mix.install([{:req, "~> 0.5"}])

base = "https://mockbird.mockbird.workers.dev/m/demo"

resp =
  Req.get!("#{base}/products",
    params: [page: 1, limit: 5, sortBy: "price", order: "desc"]
  )

[total] = Req.Response.get_header(resp, "x-total-count")  # real header, not a fixture
IO.puts("#{total} products total; page of #{length(resp.body)}")
first = hd(resp.body)
IO.puts("first: #{first["name"]} #{first["price"]}")

Output when we ran it: 30 products total; page of 5 โ€” Req decodes the JSON body to maps automatically. 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

Mix.install([{:req, "~> 0.5"}])

base = "https://mockbird.mockbird.workers.dev/m/demo"

resp = Req.post!("#{base}/products", json: %{name: "Test Widget", price: 9.99})
id = resp.body["id"]
IO.puts("created: #{resp.status} #{id}")

back = Req.get!("#{base}/products/#{id}").body
IO.puts("persisted: #{back["name"] == "Test Widget"}")

del = Req.delete!("#{base}/products/#{id}")
IO.puts("deleted: #{del.status}")

When we ran it: created: 201 31 โ†’ persisted: true โ†’ deleted: 200. 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 timeout

Mix.install([{:req, "~> 0.5"}])

base = "https://mockbird.mockbird.workers.dev/m/demo"

# any status on demand โ€” disable Req's default retry or it'll retry the 503!
resp = Req.get!("#{base}/products", params: [mock_status: 503], retry: false)
IO.puts("forced status: #{resp.status}")

# a genuinely slow response vs your client timeout
{:error, error} =
  Req.get("#{base}/products",
    params: [mock_delay: 3000],
    receive_timeout: 1000,
    retry: false
  )

IO.inspect(error)                                # %Req.TransportError{reason: :timeout}
IO.puts("timeout branch actually ran: #{error.reason == :timeout}")

Both matter. The first line contains a gotcha worth internalizing: Req retries 5xx responses out of the box (retry: :transient), so forcing a 503 without retry: false silently costs you three retries and ~4 seconds โ€” exactly the kind of default your error-handling tests need to be aware of. And the second: a stub answers in microseconds, so the code path where %Req.TransportError{reason: :timeout} comes back after a real deadline is rarely exercised. Here the server genuinely holds the response for 3 seconds and the 1-second receive_timeout really fires โ€” when we ran it, both lines printed exactly the comments above.

5. Watch Req's built-in retry actually earn its keep

Req ships retry logic by default: retry: :transient covers 408/429/500/502/503/504 with exponential backoff. Most Elixir codebases never see it fire outside an incident. Point it at an endpoint where ~half the requests fail with a random 5xx/429 โ€” then watch it work:

Mix.install([{:req, "~> 0.5"}])

base = "https://mockbird.mockbird.workers.dev/m/demo"

for i <- 0..4 do
  resp =
    Req.get!("#{base}/products",
      params: [mock_chaos: 0.5, limit: 1],
      max_retries: 5,
      retry_delay: fn attempt -> 100 * 2 ** attempt end
    )

  IO.puts("#{i} #{resp.status}")   # all 200 โ€” retries absorbed the failures
end

When we ran it: five 200s, with Req logging lines like retry: got response with status 503, will retry in 200ms, 4 attempts left in between โ€” the retry machinery visibly absorbing every injected failure. (At a 0.5 failure rate with 5 retries there's a ~2% chance per call that every attempt fails โ€” if you see a final 5xx/429, that's the give-up branch, which your code should also handle.) 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 stream you can actually test

Mix.install([{:req, "~> 0.5"}])

base = "https://mockbird.mockbird.workers.dev/m/demo"

# lazily stream every record, driven by the server's real X-Total-Count header
all_records = fn resource, limit ->
  Stream.resource(
    fn -> 1 end,
    fn
      :done ->
        {:halt, :done}

      page ->
        resp = Req.get!("#{base}/#{resource}", params: [page: page, limit: limit])
        [total] = Req.Response.get_header(resp, "x-total-count")

        if page * limit >= String.to_integer(total),
          do: {resp.body, :done},
          else: {resp.body, page + 1}
    end,
    fn _ -> :ok end
  )
end

products = all_records.("products", 10) |> Enum.to_list()
IO.puts(length(products))   # 30 โ€” 3 real round-trips of 10

Three real round-trips of 10, driven by the server's X-Total-Count โ€” and because it's a Stream, Enum.take(all_records.("products", 10), 3) stops after one request. There's also opaque cursor pagination (?cursor=) if the API you're mimicking paginates that way.

7. ExUnit: pin each async test module to a frozen snapshot

ExUnit runs async: true modules concurrently โ€” which is exactly how shared mutable test data makes suites 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 module pins the scenario it wants โ€” no fixtures, no setup ordering, and full async: true concurrency is safe because nobody mutates anything:

Mix.install([{:req, "~> 0.5"}])
ExUnit.start()

defmodule MockClient do
  @base "https://mockbird.mockbird.workers.dev/m/abc123"

  # GET a path pinned to a named snapshot; returns {x-total-count, records}
  def get(path, snapshot) do
    resp = Req.get!(@base <> path, headers: [{"x-mockbird-snapshot", snapshot}])
    [total] = Req.Response.get_header(resp, "x-total-count")
    {total, resp.body}
  end
end

defmodule BaselineTest do
  use ExUnit.Case, async: true

  test "user list is always 5, forever" do
    {_, users} = MockClient.get("/users", "baseline")
    assert length(users) == 5
  end
end

defmodule EmptyStateTest do
  use ExUnit.Case, async: true

  test "empty state renders" do
    {total, users} = MockClient.get("/users", "empty")
    assert users == []
    assert total == "0"
  end
end

We ran exactly this file before publishing: 2 tests, 0 failures in 0.6s, both modules concurrent. 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's Elixir generator and you get a full client package (Tesla-based, typed model structs with decode/1) 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 elixir -i openapi.json -o demoapi \
  --additional-properties=invokerPackage=DemoApi
cd demoapi && mix do deps.get + compile

We ran that too (openapi-generator 7.10.0): it generated model modules for products, orders, customers and reviews plus per-resource API modules โ€” and mix compile passes clean. Contract-first without writing the contract by hand; when the real backend ships, point the generated client'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-node tools are genuinely good, and each solves a different problem well. For pure unit tests they're usually the better choice. The split:

BypassMoxExVCRMockbird
What it isReal Plug server on loopback, per-test expectationsBehaviour-contract double (no HTTP at all)Record/replay cassettes of real responsesHosted mock API
Works offline / zero latencyโœ” (loopback)โœ”โœ” (after recording)โœ– (real network)
Stubs you must write & maintainEvery Bypass.expect, by handEvery expectation, by handCassettes go stale; re-recordNone โ€” CRUD, filters, search, pagination built in
Real HTTP over the wireโœ” (loopback)โœ– โ€” your client module is swapped outโœ– on replayโœ” real network, real TLS
Reachable by teammates, frontend, CI, other servicesโœ– one nodeโœ– one nodeโœ– one nodeโœ” one https URL
Real timeouts / slow responses / rate limitsPartial (hand-rolled Plug sleeps)โœ–โœ– replay is instantโœ” mock_delay / mock_chaos / mock_ratelimit
State persists across processes/runsโœ–โœ–โœ” but frozenโœ” (+ snapshots)
Verify "was this called?"โœ” expectationsโœ” verify! โ€” its superpowerโœ–Partial: request inspector
Free tierfree (hex package)free (hex package)free (hex package)free: 20 projects, 10k req/project/day

Use both: keep Mox for contract-enforced unit isolation (it pairs beautifully with a Req/Tesla behaviour) and Bypass for millisecond-fast loopback tests with call verification, and point integration tests, LiveView development, 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#, Go or Rust 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.