HTTP Stubbing in Ruby: VCR and WebMock Guide
Tests that make real HTTP requests are slow, flaky, and dependent on external services staying available. VCR and WebMock solve this by intercepting HTTP requests and either returning pre-recorded responses or raising errors when unexpected network calls happen.
This guide covers both tools, when to use each, and how to combine them for a robust HTTP stubbing strategy in Ruby and Rails applications.
The Problem with Real HTTP in Tests
Consider a test that calls a payment API, a weather service, or a third-party authentication provider. Several things can go wrong:
- The external service is down
- Your test environment lacks the right credentials
- The API rate-limits your requests
- The response changes and breaks assertions
- The test is 10x slower than it needs to be
Both VCR and WebMock prevent your tests from making real network calls, eliminating all of these problems.
WebMock
WebMock intercepts HTTP requests at the adapter level, before any network call is made. It works with Net::HTTP, HTTParty, Faraday, RestClient, Typhoeus, and most other Ruby HTTP libraries.
Installation
# Gemfile
group :test do
gem "webmock"
endBasic Usage
require "webmock/rspec" # or webmock/minitest
RSpec.describe WeatherService do
it "returns temperature from API" do
stub_request(:get, "https://api.weather.com/current")
.with(
query: { city: "NYC", key: "test-api-key" },
headers: { "Accept" => "application/json" }
)
.to_return(
status: 200,
body: { temperature: 72, condition: "sunny" }.to_json,
headers: { "Content-Type" => "application/json" }
)
result = WeatherService.current("NYC")
expect(result.temperature).to eq(72)
end
endWebMock stubs are scoped to the test and reset automatically after each example (with RSpec integration). In Minitest, include WebMock::API and call WebMock.reset! in teardown, or use the webmock/minitest integration which handles this automatically.
Matching Requests
WebMock's request matching is flexible:
# Match any request to a host
stub_request(:any, /api\.weather\.com/)
# Match with specific body
stub_request(:post, "https://api.example.com/users")
.with(body: { email: "alice@example.com" }.to_json)
# Match with regex body
stub_request(:post, "https://api.example.com/logs")
.with(body: /error/)
# Match with any body
stub_request(:post, "https://api.example.com/events")
.with(body: hash_including(event: "click"))Simulating Errors
# Network timeout
stub_request(:get, "https://api.slow.com/data")
.to_timeout
# Connection refused
stub_request(:get, "https://api.down.com/data")
.to_raise(Errno::ECONNREFUSED)
# HTTP error response
stub_request(:get, "https://api.example.com/resource")
.to_return(status: 503, body: "Service Unavailable")
# Sequence of responses (first call fails, second succeeds)
stub_request(:get, "https://api.flaky.com/data")
.to_return(status: 500)
.then
.to_return(status: 200, body: { result: "ok" }.to_json)Verifying Requests Were Made
it "calls the payment API with correct parameters" do
stub = stub_request(:post, "https://api.stripe.com/v1/charges")
.to_return(status: 200, body: { id: "ch_123", paid: true }.to_json)
PaymentService.charge(amount: 2000, token: "tok_visa")
expect(stub).to have_been_requested
expect(stub).to have_been_requested.with(
body: hash_including(amount: "2000")
)
endDisabling Real Requests
WebMock disables all real HTTP requests by default when included. You can be explicit:
# Raise on any unstubbed request (default)
WebMock.disable_net_connect!
# Allow specific hosts through (for integration tests)
WebMock.disable_net_connect!(allow: "localhost")
WebMock.disable_net_connect!(allow_localhost: true)
# Allow connection to specific URLs
WebMock.disable_net_connect!(allow: /test\.server/)VCR
VCR records real HTTP responses on the first test run and replays them on subsequent runs. This is useful when you want tests to behave like they're making real requests without actually hitting the network every time.
Installation
# Gemfile
group :test do
gem "vcr"
gem "webmock" # VCR uses WebMock (or Typhoeus) as its HTTP interceptor
endBasic Configuration
# spec/support/vcr.rb or test/support/vcr.rb
VCR.configure do |config|
config.cassette_library_dir = "test/vcr_cassettes"
config.hook_into :webmock
# Remove sensitive data from recorded cassettes
config.filter_sensitive_data("<STRIPE_API_KEY>") { ENV["STRIPE_API_KEY"] }
config.filter_sensitive_data("<WEATHER_API_KEY>") { ENV["WEATHER_API_KEY"] }
config.default_cassette_options = {
record: :new_episodes, # Record new requests, replay existing
match_requests_on: [:method, :uri, :body]
}
endUsing Cassettes in RSpec
RSpec.describe StripeService do
describe "#create_customer" do
it "creates a Stripe customer", vcr: { cassette_name: "stripe/create_customer" } do
customer = StripeService.create_customer(email: "alice@example.com")
expect(customer.id).to match(/cus_/)
expect(customer.email).to eq("alice@example.com")
end
end
endThe first time this test runs, VCR makes the real HTTP request and records the response to test/vcr_cassettes/stripe/create_customer.yml. Every subsequent run replays the recording.
Using Cassettes in Minitest
class StripeServiceTest < ActiveSupport::TestCase
test "creates a Stripe customer" do
VCR.use_cassette("stripe/create_customer") do
customer = StripeService.create_customer(email: "alice@example.com")
assert_match /cus_/, customer.id
end
end
endCassette Record Modes
VCR's record mode controls when it makes real requests vs. replaying:
VCR.use_cassette("name", record: :none) do
# Never make real requests — raise if cassette doesn't exist
end
VCR.use_cassette("name", record: :new_episodes) do
# Replay existing, record new requests not in cassette
end
VCR.use_cassette("name", record: :all) do
# Always make real requests and overwrite cassette
end
VCR.use_cassette("name", record: :once) do
# Record once, then replay forever (never re-record)
endUse :once for stable external APIs. Use :new_episodes when the API adds new endpoints you need to test. Use :none in CI to ensure all cassettes exist.
Filtering Sensitive Data
Cassettes are committed to your repository, so filter secrets before recording:
VCR.configure do |config|
config.filter_sensitive_data("<STRIPE_KEY>") { ENV["STRIPE_SECRET_KEY"] }
config.filter_sensitive_data("<AUTH_TOKEN>") do |interaction|
interaction.request.headers["Authorization"]&.first
end
endVCR replaces the actual values with the placeholder string in cassettes. On replay, it substitutes the current environment value back in.
Custom Request Matching
By default, VCR matches on method and URI. For APIs where the body matters:
VCR.use_cassette("graphql_query", match_requests_on: [:method, :uri, :body]) do
result = GraphQLClient.query(FETCH_USER_QUERY, variables: { id: 42 })
endFor REST APIs where query parameters matter:
VCR.use_cassette("search_results", match_requests_on: [:method, :uri, :query]) do
results = SearchApi.search(q: "ruby testing", page: 1)
endChoosing Between VCR and WebMock
Use WebMock when:
- You control the response data and want to define it inline
- You're testing error handling (timeouts, 500s, malformed responses)
- The external API isn't available in your environment
- You want to verify specific request parameters were sent
Use VCR when:
- You want tests to reflect real API behavior without maintaining fake responses by hand
- The API response structure is complex and changes occasionally
- You want to catch breaking API changes by re-recording periodically
Use both together:
- VCR for happy-path API recording
- WebMock for error scenario simulation within the same test suite
Combining VCR and WebMock
Configure VCR to use WebMock as its HTTP adapter, then use WebMock stubs for error scenarios:
# Happy path with VCR
VCR.use_cassette("payment_success") do
result = PaymentService.charge(amount: 1000, token: "tok_visa")
assert result.success?
end
# Error scenarios with WebMock
stub_request(:post, "https://api.stripe.com/v1/charges")
.to_return(status: 402, body: { error: { code: "card_declined" } }.to_json)
result = PaymentService.charge(amount: 1000, token: "tok_declined")
assert_equal :card_declined, result.error_codeCommon Patterns
Testing Retry Logic
stub_request(:get, "https://api.example.com/data")
.to_return(status: 503)
.then
.to_return(status: 503)
.then
.to_return(status: 200, body: { result: "ok" }.to_json)
result = ApiClient.fetch_with_retry("/data", max_retries: 3)
assert_equal "ok", result[:result]
assert_requested :get, "https://api.example.com/data", times: 3Testing Pagination
stub_request(:get, "https://api.example.com/items?page=1")
.to_return(body: { items: [1, 2, 3], next_page: 2 }.to_json)
stub_request(:get, "https://api.example.com/items?page=2")
.to_return(body: { items: [4, 5], next_page: nil }.to_json)
all_items = ApiClient.fetch_all("/items")
assert_equal [1, 2, 3, 4, 5], all_itemsShared Stubs in RSpec
RSpec.shared_context "weather api stubs" do
before do
stub_request(:get, /api\.weather\.com/)
.to_return(
status: 200,
body: { temperature: 68, condition: "cloudy" }.to_json,
headers: { "Content-Type" => "application/json" }
)
end
end
RSpec.describe WeatherWidget do
include_context "weather api stubs"
it "displays current temperature" do
expect(WeatherWidget.render).to include("68°F")
end
endCI Setup
In CI, ensure all cassettes exist before running tests (use :none record mode):
# config/environments/test.rb or vcr_helper.rb
if ENV["CI"]
VCR.configure do |config|
config.default_cassette_options = { record: :none }
end
endCommit cassettes to version control. Refresh them locally when the API changes, review the diff, and commit the updated cassettes.
Beyond Unit Tests
HTTP stubbing removes external dependencies from your test suite, but it cannot tell you if the real API has changed. Complement VCR and WebMock with contract tests (Pact) or a scheduled integration test suite that runs against the live API weekly.
For production monitoring of API-dependent features, HelpMeTest runs end-to-end scenarios 24/7 — catching real API failures before your users do.
Summary
WebMock and VCR are complementary tools. WebMock excels at explicit, inline request stubbing for error scenarios and parameter verification. VCR excels at recording and replaying complex real-world responses for happy-path tests.
Use both: VCR for your main API integration tests, WebMock for error scenario coverage. Filter sensitive data from cassettes before committing, enforce :none mode in CI so missing cassettes fail immediately, and re-record periodically to catch API drift.