One of my open source projects over at Honeybadger.io is a Ruby gem which receives webhooks from a variety of inbound email services (works with any Rack-based application) and converts them into a standard Ruby Mail::Message.

The main benefit of this is that one really doesn't need to know anything about the format of the webhooks they are consuming -- they just pass a Rack::Request to Incoming! and get back the well known Mail::Message. Incoming! also makes it a snap to switch email services.

Testing all of the various webhook payloads is not a snap, unfortunately; some post JSON, some post form parameters, and some post raw email messages as the request body. The other day I was trying to write some integration specs for the various strategies. I was looking for something which would basically record an inbound request and then allow me to replay it later. I found one option, but it wasn't quite what I was looking for -- what I wanted is basically VCR in reverse.

After trying a few failed approaches, it struck me that since a Rack::Request is the only dependency of Incoming!, that's really all I need to test it. And because a Rack::Request is basically just a Rack env Hash, then I can dump that Hash to a file and reload it as a fixture in my specs. To accomplish the first half of this (creating the fixtures), I built a little Rack application which I named FixtureRecorder:

# spec/recorder.rb
require 'rack'

FIXTURES_DIR = File.expand_path('../../spec/fixtures', __FILE__)

class FixtureRecorder
def initialize(app)
@app = app
end

def call(env)
env['fixture_file_path'] = file_path_from(env)
begin
@app.call(env)
ensure
File.open(env['fixture_file_path'], 'w') do |file|
file.write(dump_env(env))
end
end
end

def file_path_from(env)
file_path = env['PATH_INFO'].downcase.gsub('/', '_')[/[^_].+[^_]/]
file_path = 'root' unless file_path =~ /\S/
File.join(FIXTURES_DIR, [file_path, 'env'].join('.'))
end

def dump_env(env)
safe_env = env.dup
safe_env.merge!({ 'rack.input' => env['rack.input'].read })
safe_env = safe_env.select { |_,v| Marshal.dump(v) rescue false }
Marshal.dump(safe_env)
end
end

app = Rack::Builder.new do
use FixtureRecorder
run Proc.new { |env|
[200, {}, StringIO.new(env['fixture_file_path'])]
}
end

Rack::Handler::WEBrick.run app, Port: 4567

The approach is similar to requestb.in, except that it runs locally and records the Rack env directly to a fixture file. The fixtures are named using the requested path name so that I can point multiple webhooks to the same server:

A local request to http://localhost:4567/sendgrid results in the fixture spec/fixtures/sendgrid.env

In my specs, I have a little helper to load the file contents back into Ruby and instantiate the Rack::Request:

# spec/spec_helper.rb
require 'rspec'
require 'rack'

FIXTURE_DIR = File.expand_path('../../spec/fixtures', __FILE__)

RSpec.configure do |c|
# ...
module Helpers
def recorded_request(name)
fixture_file = File.join(FIXTURE_DIR, "#{name}.env")
env = Marshal.load(File.read(fixture_file))
env['rack.input'] = StringIO.new(env['rack.input'])
Rack::Request.new(env)
end
end

c.include Helpers
end

The rest is pretty simple. In order to record fixtures, I first start the Rack server:

ruby spec/recorder.rb
[...] INFO WEBrick 1.3.1
[...] INFO ruby 2.0.0 (2013-06-27) [x86_64-darwin12.4.0]
[...] INFO WEBrick::HTTPServer#start: pid=85483 port=4567

Next, I launch ngrok to expose the Rack server publicly:

ngrok                                              (Ctrl+C to quit)

Tunnel Status online
Version 1.6/1.5
Forwarding http://56e10a0f.ngrok.com -> 127.0.0.1:4567
Forwarding https://56e10a0f.ngrok.com -> 127.0.0.1:4567
Web Interface 127.0.0.1:4040
# Conn 0
Avg Conn Time 0.00ms

Lastly, I configure each webhook to post to http://56e10a0f.ngrok.com/strategy-name, and then send some emails. The requests are received, and the Rack env is dumped to spec/fixtures/strategy-name.env.

And that's it. Using the fixture in action looks like this:

# spec/integration_spec.rb
require 'spec_helper'

describe Incoming::Strategies::Mailgun do
let(:receiver) { test_receiver(:api_key => 'asdf') }

describe 'end-to-end' do
let(:request) { recorded_request('mailgun') }
before do
OpenSSL::HMAC.stub(:hexdigest).
and_return(request.params['signature'])
end

it 'converts the Rack::Request into a Mail::Message' do
expect(receiver.receive(request)).to be_a Mail::Message
end
end
end