Surprises while testing Sinatra controllers
Posted on 12 February 2012
I’ve been working with Ruby and Sinatra this week to write some RESTful interfaces. I’m new to Ruby and Sinatra, but I’m not new to dynamic languages, RESTful services, or the Rack / WSGI / PSGI / Ring view of web applications. I figured that this shouldn’t be a difficult task. Boy was I wrong.
Sinatra offers a very nice DSL for describing HTTP controllers. For example, a simple “Hello, world” controller looks like this:
require 'sinatra/base' # this is 'modular' style class HelloWorld < Sinatra::Base get '/*' do 'Hello, world!' end end
Sinatra also conforms to the Rack specification: that is, Sinatra
controllers expose a call
method which (to a first approximation)
takes an HTTP request and returns an HTTP response. We thought that
having a nice simple interface to talk to the controller would make
testing and test-driving very easy; and in fact Rack::Test::Methods
provides a pleasing DSL for testing Rack applications through the call
method:
require 'rspec' require 'rack/test' require 'hello-world' describe 'Hello World application' do include Rack::Test::Methods def app HelloWorld end it 'should return Hello, world at root URL' do get '/' last_response.body.should match /Hello, world/ end end
The trouble started because we wanted to stub out dependencies of the controller. Our “Hello, world” controller grew to look something like this:
require 'sinatra/base' # this is 'modular' style class HelloWorld < Sinatra::Base def repository raise NotImplementedException end get '/*' do repository.get('foo') end end
We’re practising top-down TDD, where we don’t try to create dependencies until we’ve found a need for them at a higher level. At this point, we wanted a test to check this code. To do this, we’re going to have to stub out the repository with a test fake Here’s attempt #1:
require 'rspec' require 'rack/test' require 'hello-world' describe 'Hello World application' do include Rack::Test::Methods def app HelloWorld end it 'should return Hello, world at root URL' do mock_repo = mock('repository') mock_repo.stub(:get).with('foo').and_return('bar') app.stub(:repository).and_return(mock_repo) get '/' last_response.body.should == 'bar' end end
We figured that since app
defines the application under test, we
could just stub out the repository on the app and we’d be done. Sadly
not; the app called the real repository
method, not our stubbed
version.
Surprise #1: HelloWorld and HelloWorld.new are both Rack applications!
Sinatra provides your controller classes with a call
method, which
internally implements the Singleton pattern and will new up a
controller instance before delegating the call
to the controller
instance.
I have just one question: why?. Is it just so that I can save 4
characters by typing def app; HelloWorld; end
rather than def app;
HelloWorld.new; end
? Sure, it saved typing, but it confused the hell
out of us.
Okay, let’s leave the controller class magic behind. Here’s attempt two:
require 'rspec' require 'rack/test' require 'hello-world' describe 'Hello World application' do include Rack::Test::Methods def app HelloWorld.new #let's have an instance now end it 'should return Hello, world at root URL' do mock_repo = mock('repository') mock_repo.stub(:get).with('foo').and_return('bar') app.stub(:repository).and_return(mock_repo) get '/' last_response.body.should == 'bar' end end
Now, app
is pointing to a HelloWorld instance, so we should be able
to stub out its dependency, right? Wrong. It still calls the real
repository
method.
Surprise #2: HelloWorld.new doesn’t create an instance of HelloWorld.
Seriously, have a look yourself:
$ irb >> require 'hello-world' => true >> HelloWorld.new.class => Sinatra::ShowExceptions
WAT.
What is going on here? Well, Sinatra has overridden Object#new with its own new method, so that when you new up a Sinatra controller you get all sorts of Rack middleware for free. This middleware does nice things like catching and formatting exceptions. That’s all fine. But why is Sinatra’s method of attaching middleware the wholesale replacement of a core language feature?
What this means is that we weren’t stubbing repository
on our app at
all; we were stubbing it on Sinatra::ShowExceptions, the outermost
middleware layer. Deep down the middleware stack, our app still had
its real repository
method intact.
Thankfully, Sinatra aliases the original Object#new as new!
in your
code, so if you want a naked controller, you can still get one. Here’s
attempt #3:
require 'rspec' require 'rack/test' require 'hello-world' describe 'Hello World application' do include Rack::Test::Methods def app HelloWorld.new! #let's have an unadulterated instance now end it 'should return Hello, world at root URL' do mock_repo = mock('repository') mock_repo.stub(:get).with('foo').and_return('bar') app.stub(:repository).and_return(mock_repo) get '/' last_response.body.should == 'bar' end end
Unfortunately, this still doesn’t work, even though we have a real
instance of HelloWorld and we really are stubbing repository
on
it. Why?
Surprise #3: Sinatra
dup
s your controller before handling an HTTP request
From ‘sinatra/base.rb’:
# Rack call interface. def call(env) dup.call!(env) end
So when you call your Sinatra instance’s call
method, it’s not
your instance at all that handles it, it’s a dup
of that
instance. And the dup
doesn’t have its methods stubbed like the
original did.
At this point, I pause and realise that if I’m fighting something this hard, it’s probably because my mental model doesn’t match Sinatra’s, and that there’s probably a way to achieve what I want which doesn’t involve having to dig around in Sinatra’s source code to work out what deep magic it is wreaking with my controllers.
The problem I have is that I already have a mental model for web application development, which is based at the Rack level. I’ve used Ring and understood it; and that means I can then go and learn Rack in about 10 minutes. Rack is simple. I like Rack.
Sinatra, on the other hand, seems to be trying to hide Rack from me. It’s a shame, because Rack seems to be the only part of the system that I understand. As a result, I’m left flailing in Sinatra’s magical kingdom wondering why the ground keeps shifting under my feet.