โ† All guides

Mock APIs in Rails โ€” WebMock, VCR, and when the mock needs to be a real URL

Credit where due first: Rails testing culture got HTTP stubbing right before almost anyone else. WebMock's stub_request is muscle memory, VCR invented the cassette pattern every other ecosystem cloned, and WebMock's default of failing loudly on any unstubbed request is a genuine superpower โ€” Laravel's Http::fake(), by contrast, silently lets unmatched requests hit the real network. For fast offline unit tests of code that calls external APIs, keep using both. This page is not going to pretend otherwise.

Everything below was verified the week of writing (Aug 2026, Rails 8.1.3, Ruby 3.2, WebMock 3.26, minitest) โ€” every snippet on this page was actually run.

The three boundaries that send people searching

1. Every stub is code you write โ€” and it's frozen data with no state

A stub returns exactly what you typed. Stub a list, stub a create, and the created record never shows up in the list โ€” because there is no data underneath, just two canned responses:

stub_request(:get, "https://api.example.com/products")
  .to_return(body: [{ id: 1, name: "Keyboard" }].to_json,
             headers: { "Content-Type" => "application/json" })
stub_request(:post, "https://api.example.com/products")
  .to_return(status: 201, body: { id: 2, name: "Mouse" }.to_json)

Net::HTTP.post(URI("https://api.example.com/products"),
               { name: "Mouse" }.to_json, "Content-Type" => "application/json")
list = JSON.parse(Net::HTTP.get(URI("https://api.example.com/products")))
list.length  # => 1 โ€” Mouse is not there; the list is whatever you froze

We ran exactly this in a Rails 8.1 test: the stubbed POST returns 201, and the stubbed GET still returns the one frozen item. You can fake state with lambdas and instance variables โ€” at which point you're maintaining a tiny hand-written API server inside your test file.

2. Nothing outside the test process ever sees a stub

WebMock patches Ruby's HTTP client libraries in the current process. We verified the boundary directly โ€” stub a host, then ask a subprocess to fetch it:

stub_request(:any, /mockbird/).to_return(body: "STUBBED")

Net::HTTP.get(URI("https://mockbird.mockbird.workers.dev/m/demo/products?_limit=1"))
# => "STUBBED"                     (in-process: intercepted)

`ruby -rnet/http -e 'print Net::HTTP.get(URI("https://mockbird.mockbird.workers.dev/m/demo/products?_limit=1"))'`
# => real JSON from the network    (subprocess: WebMock doesn't exist there)

That subprocess stands in for everything that isn't Ruby code inside your test runner: bin/rails server in development, a Sidekiq or Solid Queue worker you launched separately, the browser executing JavaScript in a system test, your teammate's laptop, a CI end-to-end job against a deployed preview. None of them can be stubbed by a gem loaded in your test process. When the not-yet-built API your app consumes needs to be visible to all of those at once, the mock has to be a URL.

3. VCR can't record an API that doesn't exist yet

VCR's superpower is replaying recorded traffic โ€” which requires traffic. On day one of building against a backend another team hasn't shipped, there's nothing to record; you're back to hand-writing every response. (And once recorded, cassettes replay instantly and identically forever: real timeout behaviour, rate-limit headers and slow responses are exactly what a cassette can't give you.)

1. Get a hosted API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"rails-demo","preset":"ecommerce"}'
# โ†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}

That's a live, seeded, stateful REST API โ€” products, orders, customers, reviews with realistic data, full CRUD, filtering, pagination, CORS. The snippets below use the public demo project so you can paste them right now.

2. Wire it in the Rails way: config.x per environment

Give every environment its own upstream. In development and test, point at the mock; in production, the real service โ€” no code changes, no conditionals:

# config/environments/development.rb  (and test.rb)
config.x.catalog_base_url = ENV.fetch("CATALOG_BASE_URL",
  "https://mockbird.mockbird.workers.dev/m/demo")

# config/environments/production.rb
config.x.catalog_base_url = ENV.fetch("CATALOG_BASE_URL")   # the real thing
# app/clients/catalog_client.rb
class CatalogClient
  def initialize(base: Rails.configuration.x.catalog_base_url)
    @base = URI(base)
  end

  def products(page: 1, limit: 5)
    uri = URI("#{@base}/products?_page=#{page}&_limit=#{limit}")
    res = Net::HTTP.get_response(uri)
    raise "upstream #{res.code}" unless res.is_a?(Net::HTTPSuccess)
    { items: JSON.parse(res.body), total: res["X-Total-Count"].to_i }
  end

  def create_product(attrs)
    uri = URI("#{@base}/products")
    res = Net::HTTP.post(uri, attrs.to_json, "Content-Type" => "application/json")
    raise "upstream #{res.code}" unless res.is_a?(Net::HTTPSuccess)
    JSON.parse(res.body)
  end
end

Now bin/rails server, a Sidekiq worker, the browser in a system test and your teammate's machine all consume the same dataset โ€” the thing no in-process stub can do. (Prefer Faraday? Identical idea; the Ruby guide has the Faraday + faraday-retry version.)

3. Integration tests against the hosted mock โ€” keep WebMock's tripwire

You don't have to give up disable_net_connect! to hit a real URL. Allow exactly one host; everything else still fails loudly:

require "test_helper"
require "webmock/minitest"

WebMock.disable_net_connect!(allow: "mockbird.mockbird.workers.dev")

class CatalogClientTest < ActiveSupport::TestCase
  test "real pagination with X-Total-Count" do
    r = CatalogClient.new.products(page: 2, limit: 3)
    assert_equal 3, r[:items].length
    assert_operator r[:total], :>=, 30      # a real header, not a stubbed one
  end

  test "create persists and is readable back" do
    created = CatalogClient.new.create_product(name: "Rails guide widget", price: 9.99)
    got = JSON.parse(Net::HTTP.get(
      URI("https://mockbird.mockbird.workers.dev/m/demo/products/#{created['id']}")))
    assert_equal "Rails guide widget", got["name"]   # state: the write actually stuck
  end
end

Both passed as written (page 2 โ†’ ids 4,5,6; X-Total-Count=30; create โ†’ read-back โ†’ cleaned up). Note what's being tested that a stub can't test: the pagination contract is the server's, and the write is real.

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

test "forced 503 raises like production would" do
  res = Net::HTTP.get_response(
    URI("https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=503"))
  assert_equal "503", res.code
end

test "slow upstream trips a strict read_timeout" do
  http = Net::HTTP.new("mockbird.mockbird.workers.dev", 443)
  http.use_ssl = true
  http.read_timeout = 1
  assert_raises(Net::ReadTimeout) do
    http.request(Net::HTTP::Get.new("/m/demo/products?mock_delay=3000"))
  end
end

Both verified. The second one is the point: WebMock's to_timeout raises the exception for you โ€” it never exercises your actual socket timeouts, TLS, DNS or keep-alive behaviour. A server that genuinely takes 3 seconds does. There's more where that came from: ?mock_chaos=0.3 fails a random 30% of requests with real 5xx/429s (point your retry code at it), ?mock_jitter adds random latency, ?mock_ratelimit serves real 429s with Retry-After โ€” see testing loading & error states and the docs.

5. Keep VCR โ€” record cassettes from the mock

VCR and a hosted mock compose nicely: record once against the mock's deterministic data, replay offline forever. First recording can't flake, cassettes contain no production secrets to filter_sensitive_data away, and when the contract changes you re-record against the updated mock instead of a live third party:

# test/vcr_test.rb โ€” verified: pass 1 records, pass 2 replays offline
VCR.configure do |c|
  c.cassette_library_dir = "test/cassettes"
  c.hook_into :webmock
end

VCR.use_cassette("catalog_products") do
  Net::HTTP.get(URI("https://mockbird.mockbird.workers.dev/m/demo/products?_limit=2"))
end

6. Deterministic data for parallel tests

Rails runs minitest in parallel by default. Instead of racing over one dataset, pin each scenario to a frozen snapshot โ€” a named, read-only copy of the project's data served per-request via a header (X-Mockbird-Snapshot: empty) with no restore races between workers. The deterministic test data guide has the full pattern.

7. Honest comparison

WebMockVCRMockbird
What it isPatches HTTP client libraries in-processRecords real traffic to cassettes, replaysHosted 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 โ€” but composes with WebMock's allow:)
Visible to rails server, Sidekiq, system-test browsers, teammates, CI e2eโœ– one processโœ– one processโœ” one https URL
Exists before the real API doesโœ” (hand-written stubs)โœ– (needs traffic to record)โœ” (seeded or imported)
Real timeouts / retries / rate limitsSimulated (to_timeout raises for you)โœ– instant replayโœ” mock_delay/mock_chaos/mock_ratelimit
CRUD, filters, pagination, searchYou write every stubOnly what was recordedBuilt in
State persists across processes/runsโœ–โœ–โœ” (+ snapshots)
Record real traffic & replayโœ–โœ” its superpowerPartial: HAR import + proxy record
Free tierfree (gem)free (gem)free: 20 projects, 10k req/project/day

Use all three: WebMock for millisecond unit tests and its unstubbed-request tripwire, VCR for replaying recorded contracts, and a hosted URL for development against unbuilt backends, integration tests with real network behaviour, and anything that lives outside your test process.

8. Ship day

Mockbird follows plain REST conventions, so switching to the real backend is one ENV["CATALOG_BASE_URL"] change. 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. Plain Ruby (Faraday, RSpec), Python, Node.js, Go, Java, PHP or Laravel 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.