Without full stack integration tests I never have complete confidence that a system will function properly. As well tested and designed as the individual components may be there is no way to truly know how they will interact without exercising them together. At its core object orientation is about message passing between objects, and it is the message passing that needs to be tested.
Avoiding Rails
Just recently I revamped Fragmenter, a multipart uploading library that handles storing and reassembling binary data. Fragmenter is designed to work with any web framework, but the most likely targets are Rails applications. Even with such a strong imperative to integration test I still didn't want to test against an entire Rails application.
Simply loading a fresh install of the current Rails (4.0.0 at the time of writing) installs 44 gems, using 33 MB of space, and takes ~1.05 seconds to load:
$ bundle list | wc -l
# 44
$ du -h vendor/ruby/2.0.0 | tail -n 1
# 40M vendor/ruby/2.0.0
$ for i in {1..10}; do time rails r ''; done 2>&1 |\
awk '{ sum += $4 } END { print sum / NR }'
# 1.05
Fragmenter provides two modules for mixing in to classes within an app, one for
models
and one for controllers
. The modules are insular and only rely on
services that Fragmenter provides, they have no reliance on Rails or Railties.
The decision to keep Fragmenter decoupled from Rails was made for ease of use
with other web frameworks, i.e. Sinatra. Decoupling gives the added benefit
of integrating against the most minimal API possible: Any class that can handle
Rack requests and responses.
class UploadsController < ApplicationController
include Fragmenter::Rails::Controller
end
class Resource < ActiveRecord::Model
include Fragmenter::Rails::Model
end
Testing Requests
All of the interaction with Fragmenter's mixins are via HTTP, making it ideal for exercising with a request spec. Using Rack Test makes sending requests to a Rack app and making assertions on the response extremely easy. The standard structure of a request spec looks like:
require 'rack/test'
describe 'A Resource' do
include Rack::Test::Methods
let(:app) do
lambda { [200, {}, 'Success!'] }
end
it 'performs a successful GET request' do
get 'http://example.com'
expect(last_response).to eq(200)
expect(last_response.body).to eq('Success!')
end
end
All that Rack::Test expects is an app
method returning an object that adheres
to the Rack interface. In the example above we have a hardcoded lambda
that will always return the same result. To test Fragmenter functionality we'll
replace the lambda with a Rack compatible class that includes Fragmenter's
controller mixin:
require 'fragmenter/rails/controller'
require 'rack/request'
class UploadsApp
include Fragmenter::Rails::Controller
attr_reader :request, :resource
def initialize(resource)
@resource = resource
end
def call(env)
@request = Rack::Request.new(env)
case request.request_method
when 'GET' then show
when 'PUT' then update
when 'DELETE' then destroy
end
end
end
When a Rails controller handles requests it automatically provides the
request
object. Here we must instantiate the request manually, which is very
straight forward. Each of the HTTP verbs is then mapped directly to the
corresponding mixed in method—acting as a micro RESTful router.
Lets write a spec to actually test the request/response cycle for one of the
UploadApp
methods:
require 'fragmenter'
require 'json'
require 'rack/test'
describe 'Uploading Fragments' do
include Rack::Test::Methods
Resource = Struct.new(:id) do
include Fragmenter::Rails::Model
def rebuild_fragments
fragmenter.rebuild && fragmenter.clean!
end
end
let(:resource) { Resource.new(200) }
let(:app) { UploadsApp.new(resource) }
it 'Stores uploaded fragments' do
header 'Content-Type', 'image/gif'
header 'X-Fragment-Number', '1'
header 'X-Fragment-Total', '2'
put '/', file_data('micro.gif')
expect(last_response.status).to eq(200)
expect(decoded_response).to eq(
'content_type' => 'image/gif',
'fragments' => %w[1],
'total' => '2'
)
header 'X-Fragment-Number', '2'
header 'X-Fragment-Total', '2'
put '/', file_data('micro.gif')
expect(last_response.status).to eq(202)
expect(decoded_response).to eq('fragments' => [])
end
The example simulates uploading two distinct parts of a very small gif
and
sets expectations about the responses it gets back. It looks like there is a
lot more going on here, but all of the methods (header
, put
) are still
provided by Rack::Test. The most notable addition is the Resource
class, a
generic model-like class that includes Fragmenter's model mixin.
Running the spec yields an unexpected error:
Failure/Error: put '/', file_data('micro.gif')
NoMethodError:
undefined method `render' for #<UploadsApp:0x007fb96c1c2168>
The render
method is the missing part of the Rails compatibility puzzle. Each
of the controller actions end with a call to render
with some json and a
status code. Looking through the signature for render it is clear that
only need to implement a small part of the functionality to get Rails
compatibility with the UploadsApp
:
require 'fragmenter'
require 'rack/request'
require 'rack/response'
class UploadsApp
# No change to the rest of the class
private
def render(options)
body = if options[:json]
JSON.dump(options[:json])
else
''
end
Rack::Response.new(body, options[:status], {}).finish do
@uploader = nil
end
end
end
The the compatible render
method in place our specs pass, and very quickly at
that!
Uploading Fragments
Stores uploaded fragments
Finished in 0.01303 seconds
1 example, 0 failures
A Solid Victory
All of the integration issues exposed by the request spec were between Fragmenter classes and Rack, there weren't any incompatibilities when it was pulled into a full Rails app.
The tradeoff of testing without Rails is that it won't be resistant to changes
in render
, but that has been stable for a long time. The risk is well
worth the savings in setup, boot time, run time, and complexity.
Please note that in reality the spec was written before the UploadApp
implementation. It made more sense to explain the process slightly out of
order.