HTTP Stubbing in Ruby: VCR and WebMock Guide

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"
end

Basic 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
end

WebMock 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")
  )
end

Disabling 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
end

Basic 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]
  }
end

Using 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
end

The 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
end

Cassette 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)
end

Use :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
end

VCR 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 })
end

For 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)
end

Choosing 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_code

Common 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: 3

Testing 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_items

Shared 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
end

CI 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
end

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

Read more