← back to all talks and articles

Testing Rails 3 routing constraints objects

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.

  • rails
  • ruby
  • rspec
Arjan van der Gaag

Arjan van der Gaag

A thirtysomething software developer, historian and all-round geek. This is his blog about Ruby, Rails, Javascript, Git, CSS, software and the web. Back to all talks and articles?

Discuss

You cannot leave comments on my site, but you can always tweet questions or comments at me: @avdgaag.