http_decoy
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.
The problem, illustrated
Three tests. Same feature. Different levels of lying.
Test 1 — WebMock (stub at the adapter layer)
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.
Test 2 — VCR (record once, replay forever)
it "charges the customer", vcr: { cassette_name: "stripe/charge" } do
result = StripeService.charge(amount: 2000)
expect(result.status).to eq "succeeded"
end
This 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.
Test 3 — http_decoy (what tests should look like)
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
end
Now 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
Install
# Gemfile
group :test do
gem "http_decoy"
end
bundle install
Quickstart (5 minutes)
1. Define your fake service
# 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
2. Load it in spec_helper
# spec/spec_helper.rb
require "http_decoy"
require "support/fakes/fake_stripe"
RSpec.configure do |config|
config.include FakeStripe.rspec_helpers
end
3. Write tests
RSpec.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
end
No setup per test. No per-test stub_request. No cassette files.
DSL Reference
Defining a server
MyFakeService = HttpDecoy.define(:my_service) do
base_url "https://api.example.com" # intercepted via WebMock automatically
# ...routes
end
Routes
get "/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] }
end
Query parameters:
get "/search" do
respond 200, json: { results: [], query: query_params[:q] }
end
Request contract validation
post "/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 } }
end
When 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.
Dynamic responses
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
Scenarios (failure simulation)
# 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)
end
Available 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.
Simulating latency
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
end
Works with respond_sequence too — set after: per entry to simulate a service that degrades over successive calls.
Stateful sequences
get "/account/balance" do
respond_sequence(
[200, { json: { balance: 1000, status: "active" } }],
[200, { json: { balance: 0, status: "active" } }],
[403, { json: { error: "Account suspended" } }]
)
end
First call → 1000. Second call → 0. Third call → 403. Wraps automatically.
Request assertions
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" })
end
Chains: .once, .twice, .times(n), .with(body: ...).
RSpec integration
Suite-wide (recommended):
RSpec.configure do |config|
config.include FakeStripe.rspec_helpers
config.include FakeSendGrid.rspec_helpers
end
Inline 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
end
Minitest integration
Same 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
end
Inline 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
end
Assertions: assert_received_request(server, method, path, times:, body:) and refute_received_request(server, method, path) mirror the RSpec have_received_request matcher's chains.
Configuration
# Opt out of WebMock auto-interception (e.g. if you manage stubs manually)
HttpDecoy.configure do |config|
config.auto_intercept = false
end
Why not WebMock / VCR? (honest comparison)
| 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.
Real-world examples
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
end
SendGrid (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
end
Internal 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
Requirements
- 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
Further Reading
- Why WebMock Stubs Lie (And What To Do About It) — the problem http_decoy was built to solve, with examples
Contributing
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 offenses
See CONTRIBUTING.md for full guidelines.
Contributors
Everyone who's made this better:
From the same author
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 |
License
MIT. See LICENSE.
http_decoy — stop testing your assumptions, start testing your contracts.