18

I'm working on an application that will be primarily served as an API (other than a few minor views, such as session/registration, which will be "standard"). I like the approach that was finalized in Railscast #350: Versioning an API, and so followed it. My routes look like:

namespace :api, :defaults => {:format => 'json'} do
  scope :module => :v1, :constraints => ApiConstraints.new(:version => 1, :default => false) do
    resources :posts, :only => [:create, :show, :destroy, :index]
  end

  scope :module => :v2, :constraints => ApiConstraints.new(:version => 2, :default => true) do
    resources :posts, :only => [:create, :show, :destroy, :index]
  end
end

In each route, my Constraint is a new ApiConstraints object, which is located in my ./lib folder. The class looks like this:

class ApiConstraints
  def initialize(options)
    @version = options[:version]
    @default = options[:default]
  end

  def matches?(req)
    @default || req.headers['Accept'].include?("application/vnd.MYAPP.v#{@version}")
  end
end

Now, when testing manually, everything works as expected. In my API, I may have between 5 and 10 controllers per version, and don't want to test that the API constraints works for each individual controller, as that makes no sense. I'm looking for one spec file that tests my API constraints, but I'm unsure of where to put that spec.

I've tried adding a spec/routing/api_spec.rb file to test things, but it's not working properly, as it complains that some things aren't provided, like so:

it "should route an unversioned request to the latest version" do
  expect(:get => "/api/posts", :format => "json").to route_to(:controller => "api/v1/posts")
end

The above throws an error even though the controller matches properly. It fails with the following error:

The recognized options <{"format"=>"json", "action"=>"index", "controller"=>"api/v1/posts"}>
did not match <{"controller"=>"api/v1/posts"}>,
difference: <{"format"=>"json", "action"=>"index"}>.

Notice that the controller was properly determined, but since I don't want to test for the format and action in this test, it errors out. I would like there to be 3 "API specs":

  • It should route an unversioned request to the latest version
  • It should default to the JSON format if none is specified
  • It should return a specified API version when requested

Does anyone have experience with writing specs for these kinds of routes? I don't want to add specs for every controller inside the API, as they're not responsible for this functionality.

Mike Trpcic
  • 25,305
  • 8
  • 78
  • 114

1 Answers1

5

Rspec's route_to matcher delegates to ActionDispatch::Assertions::RoutingAssertions#assert_recognizes

The the argument to route_to is passed in as the expected_options hash (after some pre-processing that allows it to also understand shorthand-style arguments like items#index).

The the hash that you're expecting to match the route_to matcher (i.e., {:get => "/api/posts", :format => "json"}) is not actually a well-formed argument to expect. If you look at the source, you can see that we get the path to match against via

path, query = *verb_to_path_map.values.first.split('?')

The #first is a sure sign that we're expecting a hash with just one key-value pair. So the :format => "json" component is actually just being discarded, and isn't doing anything.

The ActionDispatch assertion expects you to be matching a complete path + verb to a complete set of controller, action, & path parameters. So the rspec matcher is just passing along the limitations of the method it delegates to.

It sounds like rspec's built-in route_to matcher won't do what you want it to. So the next suggestion would be to assume ActionDispatch will do what it is supposed to do, and instead just write specs for your ApiConstraints class.

To do that, I'd first recommend not using the default spec_helper. Corey Haines has a nice gist about how to make a faster spec helper that doesn't spin up the whole rails app. It may not be perfect for your case as-is, but I just thought I'd point it out since you're just instantiating basic ruby objects here and don't really need any rails magic. You could also try requiring ActionDispatch::Request & dependencies if you don't want to stub out the request object like I do here.

That would look something like

spec/lib/api_constraint.rb

require 'active_record_spec_helper'
require_relative '../../lib/api_constraint'

describe ApiConstraint do

  describe "#matches?" do

    let(:req) { Object.new }

     context "default version" do

       before :each do
         req.stub(:headers).and_return {}
         @opts = { :version => nil, :default => true }
       end

       it "returns true regardless of version number" do
         ApiConstraint.new(@opts).should match req
       end

     end

  end

end

...aaand I'll let you figure out exactly how to set up the context/write the expectations for your other tests.

gregates
  • 6,607
  • 1
  • 31
  • 31
  • Yes, this is correct. Ideally, I want three tests in my api spec file, one to test that the default format works, one to test that it routes to a valid controller when no version is specified, and one to test that it routes to the proper version when a version IS specified. – Mike Trpcic Apr 17 '13 at 20:09
  • 1
    Well, using `route_to` you need to provide more specific expectations, like `expect(:get => "/api/posts.json"').to route_to(:controller => "api/v1/posts", :action => "index", :format => "json")`. There's unfortunately no way around that with the default rspec-rails matchers. – gregates Apr 17 '13 at 20:12
  • The issue with that is that every spec will be testing the logic from every other spec. It's essentially rolling all specs into one test, which isn't ideal. – Mike Trpcic Apr 17 '13 at 20:13
  • Yeah, I feel you, just saying that rspec's routing spec dsl is pretty limited. I get the feeling it's not used very much. Might be a decent subject for a patch to rspec-rails. – gregates Apr 17 '13 at 20:15
  • Alternatively, I suspect some would advise you to just test the `#matches?` method on the `ApiConstraint` class, and trust ActionDispatch to do its thing. – gregates Apr 17 '13 at 20:23
  • That's not a bad idea, as ActionDispatch is thoroughly tested. How would I go about writing specs for my ApiConstraints class in the lib folder? spec/lib/api_constraints_spec.rb? What needs to be included? – Mike Trpcic Apr 17 '13 at 20:32