af83

Rails 3: controllers unit testing with Rack

Disclaimer: This post is mostly about Rack. If your unfamiliar with this software, you may want to read this first.

Unit testing of controllers

Testing the Rails components in complete isolation from one another is not that easy. Obviously, integration tests exist to set the whole application stack in motion, while on the opposite, unit testing focus on asserting that small pieces of code perform well in complete isolation from each other.

Most of the web developers practicing TDD/BDD strive to unit test both their models' features and their controllers' actions. While unit testing of models is pretty straightforward, writing smart and scalable tests for controllers may not be that easy. Let's see why and how it can be achieved.

The issue at stake

Testing models in isolation is indeed straightforward: just ask something to a model by calling a method/sending a message, then check the output. Ensure that callbacks and hooks are called, that state machines, well, machine, that validations are triggered, and so on.

The issue with controllers is that they are the end user's gate-keeper in the browser, and as such have to both:

  • talk HTTP (understand and respond to requests triggered by the user's browser)
  • expose themselves through explicit endpoints, aka. URLs (http://your.app.com/foo/bar is mapped to a controller's action)

Controllers, by all means, are talkative beasts. How can we isolate them? Further more, it seems like testing controllers requires to test routing as well, for the two components are highly coupled at first glance. Actually, this assumption is made by the standard Test::Unit library, extended by Rails in ActionDispatch::IntegrationTest. It makes use of your current Rails.application routing scheme, so that when comes the time to talk to a controller's action in a test, one is expected to send a HTTP request to a registered route:

# Let's say you have the route: get :login, :to => 'session#index'

# Then, using the Unit::Test fashion, you'd write:
test "should get index" do
  get '/login'
  assert_response :success
end

Some people may think the /login mention breaks the "unit" in "unit testing". Rails provide helpers to hide this in what is called "functionnal tests":

test "should get index" do
  get :index
  assert_response :success
end

Rspec has similar DSL requirements:

# Here's some RSpec:
describe MyController do
  describe "GET #index" do
    get :index
  end
end

Notice how one is asking for a action name, :index. Yet, inspecting both IntegrationTest's and RSpec's internals makes use of Rails.application.routes. Although the test does not need to know the name of the route mapped to the controller's action, it will nonetheless try to detect routing errors and rely on the routing scheme, leading to ActionController::RoutingError exceptions if the tests do not match the routing scheme. For instance, working with scoped or constrained routes require the specs to be set up accordingly, which may be cumbersome.

What we would like here is to drop the reference to the Rails application routes altogether, and talk directly with the controller's action. We shall then test the routes apart.

Rack everywhere

As you may know, Rack acts as a generic wrapper for HTTP requests and responses. From its specification: "A Rack application is an Ruby object (not a class) that responds to call. It takes exactly one argument, the environment and returns an Array of exactly three values: The status, the headers, and the body."

Rails 3 is based on Rack. It is a Rack application itself and each controllers' action is a Rack application too. This new structure allows us to test our controllers in full isolation. Let's see how.

What we need is mocking requests to access controllers bypassing the routes. So the first step is:

env = Rack::MockRequest.env_for('/')

Rack can mock HTTP requests without actually using HTTP. A request needs to know a little about its environment, like the address of its endpoint, parameters… We attach one of the simplest environment here, the / endpoint.

Next, we obtain a reference to the Rack application that is our controller's action:

endpoint = MyController.action(:index)

What does this #action class method do? It gives us a ActionDispatch::MiddlewareStack pristine object eventually dispatching to the specified Rack endpoint (see ActionController::Metal, the parent class of every controllers, if you want to get down to the nitty-gritty).

All in all, the net result of this piece of code is that the mock request is dispatched to the proper Rack endpoint, the :index action of MyController, without any reference to the routing scheme.

This sounds good for unit testing, so let's proceed. What we want now is to trigger the mock request:

status, headers, body = endpoint.call(env)

This fires the request by sending the call message to the endpoint with the environment attached to the mock request, then waits for the endpoint do complete its duty, which it will do in complete isolation… provided you mocked/stubbed everything that is coming inside and outside the endpoint.

Digging a little further into Rack's API, we find that to recover metadata about the controller's action after it processed the incoming request, we must manipulate the ActionDispatch::Response returned as body:

controller = body.request.env['action_controller.instance'];

Despite not being the clearest methods chaining ever, it stills gives us a nice Mycontroller instance back, containing everything we need to test the request response. For instance, if we were expecting some User records through the @users instance variable, this is available with:

  controller.instance_variable_get(:@users)

Here come the tests! You may do this kind of things in RSpec expectation blocks, enjoying all its niceties for mocking, stubing and asserting. Obviously, a little bit of supplementary work would be required to hide the Rack request mocking / response handling into a nice, versatile helper.

Or you may use rack-test.

Using Rack::Test to produce clean, concise tests

With the Unit::Test backend:

include Rack::Test::Methods

def app
  MyController.action(:index)
end

it "should respond at least" 
  get "/"
  assert last_response.ok?
end

With RSpec and a fleshier example:

class TweetsController < ApplicationController
  respond_to :html

  def index
    @tweet = Tweet.find(params[:id])
    respond_with @tweet
  end
end
require 'spec_helper'

describe TweetsController do
  before :each do
    @tweet = mock
    @tweet.stub!(:id).and_return('1')
  end

  describe "#show" do
    before :each do
      Tweet.should_receive(:find).with(@tweet.id).and_return(@tweet)

      env = Rack::MockRequest.env_for('/', :params => {'id' => '1'})
      status, headers, body = TweetsController.action(:index).call(env)
      @response = ActionDispatch::TestResponse.new(status, headers, body)
      @controller = body.request.env['action_controller.instance']
      # all this stuff could easily be DRYed up using a helper of some kind
    end

    it "should respond successfully" do
      @response.should be_successful
      @response.response_code.should == 200
    end

    it "should assign the tweet as @tweet" do
      @controller.instance_variable_get(:@tweet).should == @tweet
    end
  end
end

Note the string parameters for id. This is an expectation of MockRequest which assumes no type casting. One could overcome this using the Rails type casting mechanism, but as everything is mocked and stubbed, this should not be an issue.

For an extensive overview of response assert methods, see ActionDispatch::Response and ActionDispatch::TestResponse.

Should I do that?

All of this is not the simplest thing ever, though. On the one hand, it leads to hard-wearing, independent tests.On the other hand, it is quite verbose, and may look weird for newcomers.

It is worth it if you want to test your controllers really on their own, and/or have a complex, dynamic routing scheme which clutters the specs up with subdomains, host and the like. You may also leverage rack-test functionalities to assert middlewares and embeded Rack applications.

References

blog comments powered by Disqus