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.
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.
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.
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.)
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.
config.x per environmentGive 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.)
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.
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.
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
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.
| WebMock | VCR | Mockbird | |
|---|---|---|---|
| What it is | Patches HTTP client libraries in-process | Records real traffic to cassettes, replays | 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 โ 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 limits | Simulated (to_timeout raises for you) | โ instant replay | โ mock_delay/mock_chaos/mock_ratelimit |
| CRUD, filters, pagination, search | You write every stub | Only what was recorded | 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: 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.
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.