← All guides

Mock a REST API for Ruby β€” net/http, Faraday, Minitest and RSpec against a real URL

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:

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.

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

2. net/http quickstart β€” real pagination headers

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.

3. Writes are real

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.

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

# 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.

5. Exercise real retry logic with injected chaos

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.

6. A pagination helper you can actually test

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.

7. Faraday as your client

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.

8. Minitest & RSpec: 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 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.

9. Honest comparison

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:

WebMockVCRFaraday::Adapter::TestMockbird
What it isPatches HTTP client libraries in-processRecords real traffic to cassettes, replaysStubbed adapter on one Faraday connectionHosted 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 responsesSimulated (to_timeout raises for you)βœ– instant replayβœ–βœ” mock_delay/mock_ratelimit
CRUD, filters, pagination, searchYou write every stubOnly what was recordedYou write every stubBuilt in
State persists across processes/runsβœ–βœ–βœ–βœ” (+ snapshots)
Record real traffic & replayβœ–βœ” its superpowerβœ–Partial: HAR import + proxy record
Free tierfree (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.

10. Ship day

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.

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