http_decoy

A real fake HTTP server. For real tests.

CI Gem Version Downloads License: MIT


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_method instead of payment_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_method became 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_url is declared) — the exact matching Ruby exception (Timeout::Error, Errno::ECONNRESET, Errno::ECONNREFUSED) is raised directly at your HTTP client's call site, so rescue Errno::ECONNRESET in your code sees a real instance of that class.
  • Hitting the server directly over a real socket (server.base_url with 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 (via SO_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: webmock for URL interception
  • Works with RSpec or Minitest — neither is a runtime dependency; only the integration you require gets loaded

Further Reading


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.