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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
| Bypass | Mox | ExVCR | Mockbird | |
|---|---|---|---|---|
| What it is | Real Plug server on loopback, per-test expectations | Behaviour-contract double (no HTTP at all) | Record/replay cassettes of real responses | Hosted mock API |
| Works offline / zero latency | โ (loopback) | โ | โ (after recording) | โ (real network) |
| Stubs you must write & maintain | Every Bypass.expect, by hand | Every expectation, by hand | Cassettes go stale; re-record | None โ 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 limits | Partial (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 tier | free (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.