Testing Rails with Rack::Test

The biggest news in Rails 2.3 is its support for Rack, the WSGI inspired Ruby web server interface. Of all the Rack goodness that has come along lately, the one that has me the most excited is Bryan Helmkamp's Rack::Test library, of which Bryan said "Basically, I extracted Merb’s request helper code into a small, reusable, framework agnostic library."

I loved Merb's request specs. I suspect that I'm going to like Rack::Test too.

Installing Rack::Test

We'll start by installing Rack::Test and loading it into our Rails app. Append this line to config/environments/test.rb:

config.gem "rack-test", :lib => "rack/test"

You can now use rake to install it:

$ sudo rake gems:install RAILS_ENV=test

Running tests with "rake test:rack"

We want to be able to run these tests easily with rake, just like our unit or functional tests. Create a new file called lib/tasks/rack-test.task and paste this code (courtesy of recipe 53 from the Advanced Rails Recipes book) into it:

namespace :test do
  Rake::TestTask.new(:rack => "db:test:prepare") do |t|
    t.libs << "test"
    t.pattern = "test/rack/**/*_test.rb"
    t.verbose = true
  end
  Rake::Task["test:rack"].comment = "Run the Rack::Test tests in test/rack"
end

task :test do
  Rake::Task["test:rack"].invoke
end

Note that we have also extended the :test task so that our Rack tests will get run after our unit and functional tests.

You should now be able to run rake test:rack, but it won't do anything yet. Let's test it by dropping this code into a file called test/rack/dummy_test.rb:

require File.join(File.dirname(__FILE__), *%w[.. test_helper])

class DummyTest < ActiveSupport::TestCase
  include Rack::Test::Methods

  def app
    lambda { |env| [200, {}, "Coolness"] }
  end

  test "should be hooked up properly" do
    get "/"
    assert last_response.body.include?("Cool")
  end
end

When you run rake test:rack you should get something like this:

$ rake test:rack
(in /Users/graham/data/effectif/projects/canvas)
/System/Library/Frameworks/Ruby.framework/Versions/1.8/usr/bin/ruby -I"lib:test" "/Library/Ruby/Gems/1.8/gems/rake-0.8.7/lib/rake/rake_test_loader.rb" "test/rack/login_test.rb" 
Loaded suite /Library/Ruby/Gems/1.8/gems/rake-0.8.7/lib/rake/rake_test_loader
Started
.
Finished in 0.008904 seconds.

1 tests, 1 assertions, 0 failures, 0 errors

Testing your Rails app

You'll have noticed that the app method (which is supposed to return an instance of the Rack application under test) doesn't actually have anything to do with Rails. It returns a callable object that needs to be compliant with the Rack API (apps are invoked via their call method, which is also how you invoke a lambda).

Let's fix that by making the app method return an instance of our Rails application.

For an example of how Rails instantiates a rack app have a look inside commands/server.rb. Scroll down until you find the call to Rack::Builder.new. From there we can see that Rails is using ActionController::Dispatcher.new. That should be enough for most purposes (if not you can see what else you may need to add by poking around in server.rb).

That gives us a template test file that looks something like this:

require File.join(File.dirname(__FILE__), *%w[.. test_helper])

class MyTest < ActiveSupport::TestCase
  include Rack::Test::Methods

  def app
    ActionController::Dispatcher.new
  end

  test "home page" do
    get "/"
    assert last_response.ok?
  end
end

If you want to get a bit more fancy and test things that aren't handled by ActionController::Dispatcher you can put your app together with Rack::Builder. As a contrived example, if you wanted to test for the presence of static files (I said it was contrived) you could create your app like this:

def app
  Rack::Builder.new {
    map "/" do
      use Rails::Rack::Static 
      run ActionController::Dispatcher.new
    end
  }.to_app
end

This article was written using Rails 2.3.2, Rack 1.0.0 and Rack::Test 0.3.0.

Why Bother?

Shortly after I first posted this article, crispee commented on reddit asking why you'd bother to use Rack::Test instead of the functional tests that come with Rails. It's a good question, and I completely failed to cover it. Ooops.

Firstly, Rack::Test is more similar to the Rails integration tests than to the functional tests. Functional tests only allow you test the actions and views of a single controller. Rack::Test allows you to write tests that visit pages anywhere on your site -- you can simulate the behaviour of a real browser to ensure that your entire app hangs together properly.

Secondly, both the Rails functional and integration tests are closely coupled to Rails. Imagine for a moment that you've been running a Rails app for a while now. You've invested a lot of effort in your application; users can log in and do all sorts of clever stuff. In the meantime you've been hand crafting the sales pages (e.g. home page, tour, FAQ, etc.) have added them to your app. All these static pages are served by one big controller and your routes file is getting to be a bit of a mess. It's high time that you introduced a CMS.

You've also recently stumbled across a hot new rack compatible web framework (for the sake of argument, let's call it Sinatra). You quite fancy using a simple Ruby CMS (sorry, shameless plug) while continuing to develop the main part of your application in Rails. Rack makes this very easy to do in the same Ruby process (see Rails meets Sinatra for details).

Obviously, you wrote a test to demonstrate that a new user can click on the "Buy Now" link on your home page, navigate their way through your signup forms, and end up on the home page. If you were also astute enough to write that test using Rack::Test, that test will still work after you move some of those pages into the Sinatra CMS.

If you refer to Pratik's "Rails meets Sinatra" post that I linked to above, you'll see that you'd only have to change the way the rack app is instantiated in your test:

def app
  Rack::Builder.new {
    # URLs starting with /account (logged in users) go to Rails
    map "/account" do
      run ActionController::Dispatcher.new
    end

    # Everything else goes to your CMS
    map "/" do
      run Sinatra.application
    end
  }.to_app
end

How cool is that?

Useful resources

For more information check out:

I love feedback and questions — please get in touch on Twitter or leave a comment.