Testing Rails 3 routing constraints objects

03-11-2011
Tagged rails, ruby, rspec
TLDR: Testing Rails custom constraints objects has a few gotchas with dummy requests and params caching.

I recently had to create some Rails routes with advanced constraints. Rails 3 lets you use a custom object as a constraints matcher, so you can separate and test the its logic. It was not a smooth ride.

An example: routing a file browser

Here’s a simplified example for routes matching an arbitrary level of nested directories and (optionally) a filename:

# in config/routes.rb
get '/*directories/:file' => 'browser#show',
  constraints: ExistingFilesConstraint.new
get '/*directories' => 'browser#show',
  constraints: ExistingFilesConstraint.new

The constraints object would look like this, returning a boolean value from the #matches? method:

# in lib/existing_file_constraint.rb
class ExistingFilesConstraint
  def matches?(request)
    all_directories_exist?(request.params[:directories]) and
      (
        !request.params.has_key?(:file) or
        file_exists?(request.params[:file])
      )
  end

private
  
  def all_directories_exist?(dirs)
    # ...
  end

  def file_exists?(file)
    # ...
  end
end

Here’s my first attempt at testing these routes using simple Rspec routing matchers, leaving the constraints logic as an implementation detail:

describe 'GET /foo' do
  it 'should match an existing directory' do
    Factory.create :directory, name: 'foo'
     { get: '/foo' }.should route_to(
       controller: 'browser',
       action: 'show',
       directories: 'foo'
     )
  end
end

A little too dummy

When you try to run these tests, however, they will fail. There is a bug — or missing feature, if you will — in Rails that causes the dummy request object passed to constraint objects in tests to be a little too dumb: they don’t have any parsed parameters, so #params will always be an empty hash. Sure, you’ve got request#path, but you don’t to mess around with regular expressions and string parsing yourself…

This is a problem, as it makes it hard to test the actual routing. There is no obvious workaround for this (although it is a known issue), so I stuck to testing my routes with integration tests (slow, but it works) and unit-testing my constraints object.

Parameter caching

Here I encountered a second bug in the way the request object works. I had tested my constraint object thoroughly, but for some reason my integration tests kept failing: the second route in the example just wouldn’t match.

It was not an implementation thing, as taking the first route out would pass the test. It appeared to be a priority thing, but I couldn’t figure out why Rails would insist on using and not matching my first route, while ignoring my second route.

Using the debugger, I eventually found out that the second route stubbornly got a :file parameter. Where could it have come from? I found another known Rails issue about parameter caching: call #params once in a route-matching cycle and its value gets fixed for all subsequent routes. Not good.

The solution was to use a slightly different method to access the params: #path_parameters. It works basically the same but does not trigger the erroneous caching behaviour:

class ExistingFilesConstraint
  def matches?(request)
    params = request.path_parameters
    all_directories_exist?(params[:directories]) and
      (
        !params.has_key?(:file) or
        file_exists?(params[:file])
      )
  end

private
  
  def all_directories_exist?(dirs)
    # ...
  end

  def file_exists?(file)
    # ...
  end
end

With this little change, the routes worked like a charm.

Improved testing

The only thing left to do was to properly test the routes. In hindsight, it is actually better than my first attempt:

context 'GET /foo' do
  let(:subject) { { get: '/foo' } }

  context 'when the files and directories do exist'
    before do
      ExistingFileConstraint.
        any_instance.
        should_receive(:matches?).
        and_return(true)
    end

    it do
      should route_to(
        controller: 'browser',
        action: 'show',
        directories: 'foo'
      )
    end
end
   
context 'when the files or directories do not exist' do
  before do
    ExistingFileConstraint.
      any_instance.
      should_receive!(:matches?).
      and_return(false)
  end

  it { should_not be_routable }
end

So, creating custom constraint objects allows you to neatly separate routing logic into testable objects, as long a you keep a few nasty gotchas in mind.