A real fake HTTP server. For real tests.
Your WebMock stubs are lying to you.
They test that your code constructs the right HTTP call. Not that the API would accept it. Not that the response shape matches what your code expects. Not that you haven't been sending a stale request format for six months while production quietly breaks.
http_decoy spins up a real Rack server inside your tests — one that validates incoming request contracts, computes dynamic responses from real inputs, and fails loudly the moment your code sends something wrong.
No cassettes. No scattered stubs. No surprises on deploy day.
Three tests. Same feature. Different levels of lying.
stub_request(:post, "https://api.stripe.com/v1/charges")
.with(body: { amount: "2000", currency: "usd" })
.to_return(status: 200, body: '{"id":"ch_123","status":"succeeded"}')This test passes even if:
- Your code sends
paymet_methodinstead ofpayment_method(typo, ships to prod) - Stripe adds a required field next week (stub keeps returning 200, forever)
- The response shape changes (your parser breaks in prod, not in tests)
- Your code sends
"2000"as a string but Stripe requires an integer
The stub doesn't know anything about Stripe. It just pattern-matches and returns JSON.
it "charges the customer", vcr: { cassette_name: "stripe/charge" } do
result = StripeService.charge(amount: 2000)
expect(result.status).to eq "succeeded"
endThis test passes even if:
- The cassette was recorded in 2022 and
payment_methodbecame required in 2023 - The cassette contains your actual Stripe test key (committed to git, forever)
- You need to test what happens when a card is declined (good luck editing cassette YAML)
- CI has no network access for the initial recording run
You end up with 50 YAML files nobody touches, all slowly diverging from reality.
FakeStripe = HttpDecoy.define(:stripe) do
base_url "https://api.stripe.com"
post "/v1/charges" do
requires_body :amount, :currency, :payment_method
validates :amount, type: Integer, min: 50
validates :currency, inclusion: %w[usd gbp eur]
respond 200, json: {
id: -> { "ch_#{SecureRandom.hex(8)}" },
status: "succeeded",
amount: -> { body[:amount] },
currency: -> { body[:currency] }
}
end
post "/v1/charges", scenario: :card_declined do
respond 402, json: { error: { code: "card_declined" } }
end
endNow your tests:
- Fail immediately if your code sends a missing or invalid field
- Reflect real request data back in responses — no frozen stubs
- Test failure paths with one line:
with_scenario(:card_declined) { ... } - Work offline, in CI, on a plane, in an airgapped environment
- Live in one place — define once, use across every test in the suite
# Gemfile
group :test do
gem "http_decoy"
endbundle install# spec/support/fakes/fake_stripe.rb
FakeStripe = HttpDecoy.define(:stripe) do
base_url "https://api.stripe.com"
post "/v1/charges" do
requires_body :amount, :currency, :payment_method
validates :amount, type: Integer, min: 50
respond 200, json: {
id: -> { "ch_#{SecureRandom.hex(8)}" },
status: "succeeded",
amount: -> { body[:amount] }
}
end
get "/v1/charges/:id" do
respond 200, json: {
id: -> { path_params[:id] },
status: "succeeded"
}
end
post "/v1/charges", scenario: :card_declined do
respond 402, json: {
error: { code: "card_declined", message: "Your card was declined." }
}
end
post "/v1/charges", scenario: :network_error do
raise_error :timeout
end
end# spec/spec_helper.rb
require "http_decoy"
require "support/fakes/fake_stripe"
RSpec.configure do |config|
config.include FakeStripe.rspec_helpers
endRSpec.describe StripeService do
describe "#charge" do
it "creates a charge and returns the id" do
result = StripeService.charge(amount: 2000, currency: "usd", payment_method: "pm_card_visa")
expect(result.id).to match(/\Ach_/)
expect(result.amount).to eq 2000
end
it "raises PaymentError on card decline" do
with_scenario(:card_declined) do
expect { StripeService.charge(amount: 2000, currency: "usd", payment_method: "pm_card_visa") }
.to raise_error(StripeService::PaymentError, /declined/)
end
end
it "raises NetworkError on timeout" do
with_scenario(:network_error) do
expect { StripeService.charge(amount: 2000, currency: "usd", payment_method: "pm_card_visa") }
.to raise_error(StripeService::NetworkError)
end
end
it "catches bad requests before they reach prod" do
# Missing payment_method — http_decoy raises immediately with a descriptive error
expect { StripeService.charge(amount: 2000, currency: "usd") }
.to raise_error(HttpDecoy::HandlerContext::ContractError, /payment_method is required/)
end
end
endNo setup per test. No per-test stub_request. No cassette files.
MyFakeService = HttpDecoy.define(:my_service) do
base_url "https://api.example.com" # intercepted via WebMock automatically
# ...routes
endget "/path"
post "/path"
put "/path"
patch "/path"
delete "/path"Path parameters:
get "/users/:id/posts/:post_id" do
respond 200, json: { user_id: path_params[:id], post_id: path_params[:post_id] }
endQuery parameters:
get "/search" do
respond 200, json: { results: [], query: query_params[:q] }
endpost "/orders" do
requires_body :item_id, :quantity # presence check
validates :quantity, type: Integer, min: 1 # type + range
validates :status, inclusion: %w[pending paid] # enum
respond 201, json: { order_id: -> { SecureRandom.uuid } }
endWhen validation fails, http_decoy raises HttpDecoy::HandlerContext::ContractError with a message naming the exact field and rule. Your test fails at the right place, with the right message.
Use lambdas anywhere in the response body — evaluated at request time with access to the full request:
post "/echo" do
respond 200, json: {
received_at: -> { Time.now.iso8601 },
you_sent: -> { body },
your_ip: -> { request.ip }
}
end# Definition
post "/payments", scenario: :rate_limited do
respond 429, json: { error: "Too many requests" }, headers: { "Retry-After" => "30" }
end
post "/payments", scenario: :timeout do
raise_error :timeout
end
# Usage in tests
with_scenario(:rate_limited) do
expect { PaymentService.pay(100) }.to raise_error(PaymentService::RateLimitError)
endAvailable transport errors: :timeout, :reset, :refused.
How the client observes this depends on how the request reached http_decoy:
- Through WebMock's interception (the default, whenever
base_urlis declared) — the exact matching Ruby exception (Timeout::Error,Errno::ECONNRESET,Errno::ECONNREFUSED) is raised directly at your HTTP client's call site, sorescue Errno::ECONNRESETin your code sees a real instance of that class. - Hitting the server directly over a real socket (
server.base_urlwith no WebMock in between — e.g. an SDK-under-test that owns its own connection) — there's no HTTP status code for "the connection died," so http_decoy actually terminates the TCP connection (viaSO_LINGER, which forces the kernel to send RST) rather than returning a response. Your client sees a genuine connection failure, not a 500.
Either way, an unhandled exception from your own handler code (a real bug, not raise_error) still returns a normal 500 with the error message in the body — that path is unchanged.
respond accepts after: (seconds) to delay the response — useful for testing timeout thresholds, loading states, or spinners against a real clock instead of a raised exception:
get "/slow-report" do
respond 200, json: { status: "ready" }, after: 2.5
endWorks with respond_sequence too — set after: per entry to simulate a service that degrades over successive calls.
get "/account/balance" do
respond_sequence(
[200, { json: { balance: 1000, status: "active" } }],
[200, { json: { balance: 0, status: "active" } }],
[403, { json: { error: "Account suspended" } }]
)
endFirst call → 1000. Second call → 0. Third call → 403. Wraps automatically.
it "sends the right payload" do
StripeService.charge(amount: 500, currency: "usd", payment_method: "pm_123")
expect(fake_server(:stripe)).to have_received_request(:post, "/v1/charges")
.once
.with(body: { amount: 500, currency: "usd" })
endChains: .once, .twice, .times(n), .with(body: ...).
Suite-wide (recommended):
RSpec.configure do |config|
config.include FakeStripe.rspec_helpers
config.include FakeSendGrid.rspec_helpers
endInline per describe block:
RSpec.describe "degraded upstream" do
include HttpDecoy::RSpec
fake_server(:api) do
get "/status" do
respond 503, json: { status: "degraded" }
end
end
it "handles it gracefully" do
expect(MyApp.health_check).to eq :degraded
end
endSame DSL, same HttpDecoy.define definitions — require "http_decoy/minitest" never loads RSpec, and vice versa.
Suite-wide (recommended) — same FakeStripe definition as above, shared across both frameworks:
class ChargeTest < Minitest::Test
include FakeStripe.minitest_helpers
def test_charges_the_card
StripeService.charge(500)
assert_received_request fake_server(:stripe), :post, "/v1/charges"
end
endInline per test class:
class DegradedUpstreamTest < Minitest::Test
include HttpDecoy::Minitest
fake_server(:api) do
get "/status" do
respond 503, json: { status: "degraded" }
end
end
def test_handles_it_gracefully
assert_equal :degraded, MyApp.health_check
end
endAssertions: assert_received_request(server, method, path, times:, body:) and refute_received_request(server, method, path) mirror the RSpec have_received_request matcher's chains.
# Opt out of WebMock auto-interception (e.g. if you manage stubs manually)
HttpDecoy.configure do |config|
config.auto_intercept = false
end| WebMock | VCR | http_decoy | |
|---|---|---|---|
| Real server | optional | No | Yes |
| Request contract validation | possible | No | Yes |
| Dynamic responses | yes | No | Yes |
| Failure scenario testing | Verbose | Very hard | One line |
| Works offline | Yes | First run: No | Yes |
| Secrets in version control | No | Risk | No |
| Cassettes to maintain | No | Yes | No |
| Define once, use everywhere | Requires setup | Yes | Yes |
| Catches API drift | possible | No | Yes |
http_decoy uses WebMock internally to intercept requests — complementary, not a replacement.
Stripe (payments)
FakeStripe = HttpDecoy.define(:stripe) do
base_url "https://api.stripe.com"
post "/v1/payment_intents" do
requires_body :amount, :currency, :payment_method
validates :amount, type: Integer, min: 50
respond 200, json: {
id: -> { "pi_#{SecureRandom.hex(12)}" },
status: "succeeded",
amount: -> { body[:amount] },
currency: -> { body[:currency] },
payment_method: -> { body[:payment_method] }
}
end
post "/v1/payment_intents", scenario: :insufficient_funds do
respond 402, json: {
error: { code: "insufficient_funds", decline_code: "insufficient_funds" }
}
end
endSendGrid (email)
FakeSendGrid = HttpDecoy.define(:sendgrid) do
base_url "https://api.sendgrid.com"
post "/v3/mail/send" do
requires_body :to, :from, :subject, :content
respond 202, text: ""
end
post "/v3/mail/send", scenario: :invalid_email do
respond 400, json: { errors: [{ message: "Invalid email address" }] }
end
endInternal microservice
FakeInventory = HttpDecoy.define(:inventory) do
base_url "https://inventory.internal"
get "/products/:sku/stock" do
respond 200, json: {
sku: -> { path_params[:sku] },
stock: -> { rand(0..100) },
unit: "each"
}
end
get "/products/:sku/stock", scenario: :out_of_stock do
respond 200, json: { sku: -> { path_params[:sku] }, stock: 0 }
end
get "/products/:sku/stock", scenario: :service_down do
respond 503, json: { error: "Inventory service is down" }
end
end- Ruby 3.1+
- Runtime dependencies:
webrick,rack(both lightweight) - Optional:
webmockfor URL interception - Works with RSpec or Minitest — neither is a runtime dependency; only the integration you
requiregets loaded
- Why WebMock Stubs Lie (And What To Do About It) — the problem http_decoy was built to solve, with examples
I built this myself — which means it works great for the cases I thought of, and probably has rough edges for the ones I didn't. If you hit something weird, open an issue. I read them all and respond fast.
Want to fix something or add a feature? Send a PR. No CLA, no process overhead, no committee review. If the tests pass and the change makes sense, it's getting merged. I'm one person and I genuinely appreciate the help — you can take this further than I can alone.
Not sure where to start? Look for good first issue labels, or just open an issue and ask.
git clone https://github.com/jibranusman95/http_decoy
cd http_decoy
bundle install
bundle exec rspec # RSpec suite
bundle exec rake test # Minitest suite
bundle exec rubocop # no new offensesSee CONTRIBUTING.md for full guidelines.
Everyone who's made this better:
Small, sharp Ruby gems built to the same standard — 100% test coverage, zero dependencies beyond what's needed.
| Gem | What it does |
|---|---|
| llm_cassette | VCR for LLMs — streaming-aware cassette recorder for OpenAI and Anthropic |
| turbo_presence | Figma-style live cursors, avatar stacks, and typing indicators for Rails/Hotwire |
| promptscrub | PII redaction middleware for LLM calls — strip sensitive data from prompts, rehydrate in responses |
| webhook_inbox | Transactional inbox for Rails webhook receivers — deduplication, async processing, replay, dashboard |
| agent_jail | Fork-based sandbox for LLM tool calls — timeout, memory limit, and filesystem restrictions |
MIT. See LICENSE.
http_decoy — stop testing your assumptions, start testing your contracts.