Ruby practically invented in-process HTTP faking: WebMock patches the client libraries and stub_request is muscle memory for every Rails developer, VCR β the original cassette recorder every other language cloned β replays recorded traffic, and Faraday ships its own Faraday::Adapter::Test stubs. For fast unit tests they're the right tools, and this guide's comparison table says so plainly.
But a patched client can't help when you need a URL:
net/http or writing a gem README and want an example API readers can also write to (JSONPlaceholder fakes its writes β POST returns an id, then the record isn't there).The boring fix is a hosted mock API. Every snippet below was run verbatim against the live demo project before publishing (Ruby 3.2) β you can paste and run them too.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"name":"ruby-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.
require "net/http"
require "json"
BASE = "https://mockbird.mockbird.workers.dev/m/demo"
uri = URI("#{BASE}/products")
uri.query = URI.encode_www_form(page: 1, limit: 5, sortBy: "price", order: "desc")
res = Net::HTTP.get_response(uri)
raise "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
products = JSON.parse(res.body)
total = res["X-Total-Count"].to_i # real header, not a fixture
puts "#{total} products total; page of #{products.length}"
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.
res = Net::HTTP.post(URI("#{BASE}/products"),
{ name: "Test Widget", price: 9.99 }.to_json,
"content-type" => "application/json")
new_product = JSON.parse(res.body) # 201, the stored record
back = JSON.parse(Net::HTTP.get(URI("#{BASE}/products/#{new_product["id"]}")))
raise unless back["name"] == "Test Widget" # it persisted
http = Net::HTTP.new("mockbird.mockbird.workers.dev", 443)
http.use_ssl = true
http.delete("/m/demo/products/#{new_product["id"]}") # 404s afterwards
POST returns 201 with the stored record; 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.
# any status code on demand
uri = URI("#{BASE}/products?mock_status=503")
res = Net::HTTP.get_response(uri)
raise unless res.code == "503"
# a genuinely slow response vs your client timeout
slow = URI("#{BASE}/products?mock_delay=3000")
http = Net::HTTP.new(slow.host, slow.port)
http.use_ssl = true
http.read_timeout = 1 # seconds
begin
http.get(slow.request_uri)
rescue Net::ReadTimeout
puts "timeout branch actually ran"
end
That second one matters: an in-process stub returns instantly (WebMock can simulate timeouts with to_timeout, but it raises the error for you rather than letting your socket genuinely wait). Here the server really holds the response for 3 seconds and Net::ReadTimeout really fires out of the socket read.
faraday-retry has real knobs (which methods, which statuses, backoff factor, Retry-After honoring). Point it at an endpoint where ~half the requests fail with a random 5xx/429 and watch it earn its keep:
require "faraday"
require "faraday/retry"
conn = Faraday.new(url: "https://mockbird.mockbird.workers.dev/m/demo") do |f|
f.request :retry,
max: 5,
interval: 0.3,
backoff_factor: 2,
retry_statuses: [429, 500, 502, 503, 504],
methods: [:get]
f.adapter Faraday.default_adapter
end
5.times do |i|
res = conn.get("products", mock_chaos: 0.5, limit: 1)
puts "#{i} #{res.status}" # all 200 β the middleware absorbed the injected failures
end
Ran it: five 200s. 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.
def all_records(resource, limit: 10)
page, out, total = 1, [], nil
while total.nil? || out.length < total
uri = URI("#{BASE}/#{resource}")
uri.query = URI.encode_www_form(page: page, limit: limit)
res = Net::HTTP.get_response(uri)
raise "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
total = res["X-Total-Count"].to_i
out.concat(JSON.parse(res.body))
page += 1
end
out
end
raise unless all_records("products").length == 30 # 3 real round-trips of 10
There's also opaque cursor pagination (?cursor=) if the API you're mimicking paginates that way.
require "faraday"
api = Faraday.new(url: "https://mockbird.mockbird.workers.dev/m/demo") do |f|
f.response :json # parse JSON bodies automatically
f.response :raise_error
end
%w[products orders customers].each do |resource|
res = api.get(resource, limit: 3)
puts "/#{resource} #{res.status} #{res.body.length}"
end
Because it's a real URL, the exact same connection object works in production with url: ENV["API_URL"] β the mock never appears in your code, only in configuration.
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 before resets, no ordering constraints β and parallelize_me! is safe because nobody mutates anything:
require "minitest/autorun"
require "net/http"
require "json"
BASE = "https://mockbird.mockbird.workers.dev/m/abc123"
def api_get(path, snapshot:)
uri = URI("#{BASE}#{path}")
req = Net::HTTP::Get.new(uri)
req["X-Mockbird-Snapshot"] = snapshot # reads come from the frozen dataset
Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
end
class UsersTest < Minitest::Test
parallelize_me! # safe: nobody mutates anything
def test_user_list
res = api_get("/users", snapshot: "baseline")
assert_equal 5, JSON.parse(res.body).length # baseline: always 5
end
def test_empty_state
res = api_get("/users", snapshot: "empty")
assert_equal [], JSON.parse(res.body)
assert_equal "0", res["X-Total-Count"]
end
end
We ran exactly this file before publishing: 2 runs, 3 assertions, 0 failures in 0.18s. Prefer RSpec? Same trick β and this variant runs with zero setup because the public demo ships with built-in empty and edge-cases snapshots (100-char names, unicode, price 0, HTML-ish strings):
require "net/http"
require "json"
BASE = "https://mockbird.mockbird.workers.dev/m/demo"
def products(snapshot)
uri = URI("#{BASE}/products")
req = Net::HTTP::Get.new(uri)
req["X-Mockbird-Snapshot"] = snapshot
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
[JSON.parse(res.body), res]
end
RSpec.describe "products by scenario" do
it "renders the empty state" do
list, res = products("empty")
expect(list).to eq([])
expect(res["X-Total-Count"]).to eq("0")
end
it "survives edge-case data" do
list, _ = products("edge-cases")
expect(list).to include(a_hash_including("price" => 0)) # free item
expect(list.map { |p| p["name"] }).to include(a_string_matching(/ΓnΓ―code/))
end
end
Ran that too: 2 examples, 0 failures. The same trick scales per-scenario β edge-cases, bug-repro-1234 β see the deterministic test data guide.
The in-process tools are genuinely good, and for pure unit tests they're usually the better choice. (Also: rack-test is for driving your own Rack app in-process β different job entirely, keep using it.) The split:
| WebMock | VCR | Faraday::Adapter::Test | Mockbird | |
|---|---|---|---|---|
| What it is | Patches HTTP client libraries in-process | Records real traffic to cassettes, replays | Stubbed adapter on one Faraday connection | Hosted mock API |
| Works offline / zero latency | β | β (after recording) | β | β (real network) |
| Catches accidental real HTTP in tests | β disable_net_connect! β a genuine superpower | β (unrecorded β error) | β | β (it is real HTTP) |
| Reachable by teammates, frontend, CI, other services | β one process | β one process | β one connection object | β one https URL |
| Exists before the real API does | β (hand-written stubs) | β (needs traffic to record) | β (hand-written) | β (seeded or imported) |
| Real timeouts / retry-after / slow responses | Simulated (to_timeout raises for you) | β instant replay | β | β mock_delay/mock_ratelimit |
| CRUD, filters, pagination, search | You write every stub | Only what was recorded | You write every stub | Built in |
| State persists across processes/runs | β | β | β | β (+ snapshots) |
| Record real traffic & replay | β | β its superpower | β | Partial: HAR import + proxy record |
| Free tier | free (gem) | free (gem) | free (ships with Faraday) | free: 20 projects, 10k req/project/day |
Use both: keep WebMock for millisecond-fast unit tests (and its accidental-real-HTTP tripwire), and point integration tests, gem READMEs, teaching materials and not-yet-built-backend work at a hosted URL.
Mockbird follows plain REST conventions, so switching to the real backend is one base-URL change (ENV["API_URL"] in a Faraday connection or Rails credential). Hand your backend team the live contract while they build it: https://mockbird.mockbird.workers.dev/m/abc123/openapi.json, a Postman collection, or generated TypeScript types for the frontend half.