Philip Potter

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 dups 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.