From Stubbing Frameworks to Real Rack Apps

Service-oriented architecture (SOA) breaks up the monolithic codebase, but it introduces a practical problem: running any single application in isolation now requires standing up a web of external dependencies. At Heroku, where the platform’s core is split into many backend services, the early solution was to stub those services at a high level so developers and test suites could run without remote calls.

The simplest approach was to use a stubbing framework to mock methods that touch external services. That gets tests passing, but it couples the test to the service handler’s internal interface and never exercises the handler logic itself. The next iteration was to design handlers that could mock themselves:

Addons::Client.mock! if ENV["RACK_ENV"] == "test"

That worked for tests, but pushed the burden onto developers to locate a staging instance of the remote service. Even with well-maintained shared staging environments, a broken staging service owned by another team could stall development. The natural follow-up was to build handlers with dual behavior: minimal in-process mocks for dev and test, switching to real remote calls in staging and production:

def detect_handler
  return RealHandler.new if ENV.has_key?("CORE_SHUSHU_URL")
  MockHandler.new
end

This kind of hybrid reduces setup friction, but it still splits the codebase into two paths: what runs locally is not the same code that runs in production, so any divergence must be caught in staging, where debugging is slower than it would be against a local production copy.

The Rack Stub Pattern

While restructuring the API code, the team moved away from special-purpose stub handlers toward genuine implementations written as Rack-compliant applications. These Rack stubs implement only the subset of the foreign API the calling app relies on, simplified to return just enough for a correct response and little else. A working Sinatra-based stub for a backend service looks like this:

class IonStub < Sinatra::Base
  post "/endpoints" do
    status 201
    content_type :json
    MultiJson.encode({
      id:           123,
      cname:        "tokyo-1234.herokussl.com",
      elb_dns_name: "elb016353-1923944129.us-east-1.elb.amazonaws.com",
    })
  end
end

Because the stub is a functioning app in its own right, it is immediately reusable in both development and testing. On a platform with trivial deployment, the same stub can even be pushed to the cloud and shared by staging environments.

Testing Against HTTP

In tests, Rack stubs pair cleanly with WebMock’s Rack support, which intercepts requests aimed at a particular URL and forwards them to the stub. A couple of helpers in the API test suite encapsulate the setup:

# generic helper for use with any service
def stub_service(uri, stub, &block)
  uri = URI.parse(uri)
  port = uri.port != uri.default_port ? ":\#{uri.port}" : ""
  stub = block ? Sinatra.new(stub, &block) : stub
  stub_request(:any, /^\#{uri.scheme}:\/\/(.*:.*@)?\#{uri.host}\#{port}\/.*$/).
    to_rack(stub)
end

# One-liners specifically for a specific stubs, pointing to configured
# locations of each remote service. A configuration value might look like:
#
#    ADDONS_URL=https://api-user:[email protected]
#

def stub_addons
  stub_service(ENV["ADDONS_URL"], AddonsStub, &block)
end

def stub_ion(&block)
  stub_service(ENV["ION_URL"], IonStub, &block)
end

Once the stub is registered, a test can make a remote call that transparently routes to the stub’s handler:

it "should make a call to ion" do
  stub_ion
  endpoint = IonAPI.create_endpoint!
end

This is useful where the goal is to exercise as much of the application stack as possible, since the point of stubbing is this layer—HTTP rather than a local service library—means nearly all production code runs. Error scenarios are also easy: extend the stub with Sinatra’s DSL for a particular test case and hit the failure path.

it "should raise an error on a bad ion response" do
  stub_ion do
    post("/endpoints") { 422 }
  end
  lambda do
    IonAPI.create_endpoint!
  end.should raise_error(IonAPI::Error)
end

Running Stubs Locally

Each Rack stub includes a small conditional block so it can boot as an app on its own. Invoking the filename directly starts Sinatra:

class IonStub < Sinatra::Base
  ..
end

if __FILE__ == $0
  $stdout.sync = $stderr.sync = true
  IonStub.run! port: 5100
end
$ ruby test/test_support/service_stubs/ion_stub.rb
>> Listening on 0.0.0.0:5100, CTRL+C to stop

With the stub running, an environment variable points the main application at it during development. Once configured, the main app boots and requests flow through to the service stub, mimicking part of a larger service mesh with minimal ceremony. In practice, this means a request can provision a resource as if it were hitting the real infrastructure:

$ export HEROKU_HOST=http://localhost:5000
$ heroku addons:add ssl:endpoint
$ heroku certs:add secure.example.org.pem secure.example.org.key
Adding SSL Endpoint to great-cloud... done
WARNING: ssl_cert provides no domain(s) that are configured for this Heroku app
great-cloud now served by tokyo-1234.herokussl.com
Certificate details:
Common Name(s): alt1.example.org
                alt2.example.org
                secure.example.org

Expires At:     2031-05-05 19:05 UTC
Issuer:         /C=US/ST=California/L=San Francisco/O=Heroku/CN=secure.example.org
Starts At:      2011-05-10 19:05 UTC
Subject:        /C=US/ST=California/L=San Francisco/O=Heroku/CN=secure.example.org
SSL certificate is self signed.

Booting stubs by hand works, but Foreman automates it. Add stubs to the Procfile alongside the main app, and put the stub addresses in the local .env:

web:     bundle exec puma --quiet --threads 8:32 --port 5000 config.ru

# stubs
ionstub: bundle exec ruby service_stubs/ion_stub.rb
ION_URL=http://localhost:5100

A single foreman start now brings up the whole cluster:

18:18:22 web.1              | listening on addr=0.0.0.0:5000 fd=13
18:18:22 ionstub.1          | == Sinatra/1.3.5 has taken the stage on 5100 for development with backup from WEBrick

The convenience compounds as the roster of stubs grows. With many backend services registered in the process list, a single command boots a complete development stack, closely approximating production’s topology through actual HTTP traffic. A runnable example of the project is available from brandur/service-stub-example.