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)
This stub will return 200 whether you send:
- A perfectly formed request
- A request missing the required
amountfield - 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
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
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(...) }
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
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
# Gemfile
gem "http_decoy", group: :test
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.