Why WebMock Stubs Lie (And What To Do About It)

ruby dev.to

You've written the test. WebMock is set up. The stub returns 200. Everything is green.

Then Stripe ships a breaking change, your code sends a malformed request body, and you find out in production.

Your tests lied to you.


The Problem With Stubs

WebMock is excellent at what it does. But what it does is intercept HTTP calls and return whatever you told it to return — regardless of whether your request was valid.

stub_request(:post, "https://api.stripe.com/v1/charges")
  .to_return(status: 200, body: { id: "ch_123" }.to_json)
Enter fullscreen mode Exit fullscreen mode

This stub will return 200 whether you send:

  • A perfectly formed request
  • A request missing the required amount field
  • A request with a completely wrong content type
  • Nothing at all

You're not testing that your integration works. You're testing that your code calls a URL.


VCR Cassettes Are Worse

VCR records real HTTP interactions and replays them. That sounds great until:

  • Cassettes go stale. The API changes, your cassette keeps replaying the old response forever.
  • Diffs are unreadable. Cassette files are YAML blobs that nobody wants to review in a PR.
  • Re-recording is painful. You need real credentials, a real network, and hope the API behaves the same way twice.
  • Parallel tests break. Cassettes aren't built for concurrent access.

You end up with a test suite that passes in CI and quietly lies about your production behaviour for months.


What Actually Helps: A Real Server

What if your test spun up an actual HTTP server — one that could validate your requests, enforce contracts, and simulate real failure modes?

That's what http_decoy does.

HttpDecoy.define(:payments) do
  base_url "https://payments.example.com"

  post "/charge" do
    requires_body :amount, :currency
    respond status: 201, json: { id: "ch_123", status: "success" }
  end

  post "/charge", scenario: :decline do
    respond status: 402, json: { error: "card_declined" }
  end
end
Enter fullscreen mode Exit fullscreen mode

This is a real WEBrick server running on a random port inside your test process. Your code makes actual HTTP calls to it. WebMock integration is automatic — no config needed.


In Your RSpec Tests

RSpec.describe PaymentsService do
  include HttpDecoy.define(:payments).rspec_helpers

  fake_server :payments

  it "charges the card" do
    result = PaymentsService.charge(amount: 100, currency: "usd")
    expect(result).to be_success
  end

  it "handles declines gracefully" do
    with_scenario(:decline) do
      result = PaymentsService.charge(amount: 100, currency: "usd")
      expect(result).to be_declined
    end
  end

  it "validates the request body" do
    expect { PaymentsService.charge(amount: nil) }
      .to raise_error(PaymentsService::InvalidRequest)
  end
end
Enter fullscreen mode Exit fullscreen mode

Notice what changed: the server validates that amount and currency are present. If your code doesn't send them, the request fails — exactly like the real API would.


What This Catches That WebMock Doesn't

Scenario WebMock http_decoy
Missing required field Passes silently Fails the request
Wrong content type Passes silently Configurable validation
Stale response format Invisible Update the fake, tests break
Network timeout simulation Awkward raise_error Net::ReadTimeout
Scenario switching Multiple stubs with_scenario(:decline)
Request log inspection Not built in have_received_request matcher

Scenario Switching

Testing sad paths with WebMock means redefining stubs inside individual tests — messy and hard to reuse. With http_decoy, scenarios are first-class:

HttpDecoy.define(:payments) do
  base_url "https://payments.example.com"

  post "/charge" do
    respond status: 201, json: { id: "ch_123" }
  end

  post "/charge", scenario: :rate_limited do
    respond status: 429, json: { error: "rate_limit_exceeded" }
  end

  post "/charge", scenario: :server_error do
    respond status: 500, json: { error: "internal_server_error" }
  end
end

# In tests:
with_scenario(:rate_limited) { PaymentsService.charge(...) }
with_scenario(:server_error) { PaymentsService.charge(...) }
Enter fullscreen mode Exit fullscreen mode

Inspecting Requests

it "sends the correct currency" do
  PaymentsService.charge(amount: 100, currency: "usd")

  expect(payments_server).to have_received_request(:post, "/charge")
    .with_body(including("currency" => "usd"))
end
Enter fullscreen mode Exit fullscreen mode

This isn't checking that your code built the right hash. It's checking what actually went over the wire.


Getting Started

gem install http_decoy
Enter fullscreen mode Exit fullscreen mode
# Gemfile
gem "http_decoy", group: :test
Enter fullscreen mode Exit fullscreen mode

Works with WebMock out of the box. No additional config for existing test suites.

Full docs and examples: https://github.com/jibranusman95/http_decoy


The Bottom Line

WebMock is the right tool for unit tests where you want fast, isolated behaviour. But for integration tests that touch real service boundaries, you want a server that enforces contracts — not a stub that returns whatever you asked it to.

Your cassettes are lying to you. Ship a fake that tells the truth.


Built this? Feedback welcome in the comments or open an issue on GitHub.

Source: dev.to

arrow_back Back to Tutorials