Sunday, 13 April 2014

Reflecting Routes

On an app I'm working on, several of the resources appear in the routes nested in various places. I was wondering: Is there a way to be sure that my controller test is hitting all of the routes for this controller?

The simplest use case for nesting a controller under different resources is index pages where the list is scoped to the resource it is under. Consider a simple blog app which has users with multiple blogs, with multiple posts under each blog. Posts may be listed under a specific blog, or under a user.

In more complex applications, a resource may have relationships with multiple other resources. For example, an Order may belong to a Supplier and to a Customer. Certain actions on the Order may take place nested under the Supplier or Customer according to the needs of the system.

Joining some dots.

An rspec controller test may show the following:

    describe "GET show" do
      context "under supplier" do
        before { get :show, supplier_id: 1, id: 2 }
      end
    end

We know that this will call the controller's show action with the supplier_id and id keys set in the params hash. It doesn't go straight there, however, the request parameters in the controller spec are used to build a full request path, and this is dispatched to the app through the application's routes. We know this does happen because if you forget a parameter, making the route invalid, the call never makes it to the controller, and you end up with an error (ActionController::RoutingError in Rails 3).

Capturing controller actions.

The controller test methods get, post, are defined in ActionController::TestCase. They call process with the specified HTTP method, which sets up the controller instance, request and response objects, and so on. Overloading this process method seems like a great place to start.

We can create a module and include it in all our controller test that defines this process method to inspect what is going on.

    module ControllerChecking
      def process(*)
        super # call through to ActionController::TestCase
      end
    end

We can get the method of the request from request.method and path of the request from request.fullpath. The parameters are more tricky. The parameters passed from the controller test are incomplete. They are missing the controller and action (and possibly format or other values specified in config/routes.rb). They can be found in the request object at request.env["action_dispatch.request.parameters"]. Be sure to check for nils that may happen when there are exceptions raised in the controller.

Digging into routes.

Rails routes are accessible from the Rails.application.routes.routes object. The first routes is the ActionDispatch::Routing::RouteSet, which contains URL helpers, and the second is the Journey::Routes collection of routes. Each route in the Journey::Routes collection has a few interesting methods on it:

  • verb (Regexp)
  • path (Journey::Path::Pattern)
  • defaults (Hash)

verb and path can be used to match the values we got above, and defaults is a hash of default values to be added to the parameters for the request.

So we can find all of the routes for our controller action by doing this:

    parameters = request.env["action_dispatch.request.parameters"]
    routes = Rails.application.routes.routes.select do |route|
      route.defaults[:controller] == parameters[:controller] &&
        route.defaults[:action] == parameters[:action]
    end

And then see if the controller action we just tested matched one of those routes with route.verb =~ request.method && route.path =~ path. We can use a hash to collect the routes we are interested in as keys, and a number in the value to count how many tests hit that route.

    counts = {}
    routes.each do |route|
      verb = route.verb.source.gsub(/[$^]/, '') # convert Regexp back to clean string
      key = "#{route.defaults[:controller]}##{route.defaults[:action]}  #{verb}  #{route.path.spec}" 
      # eg: "blogs#show  GET  /blogs/:blog_id/posts/:id"
      counts[key] ||= 0
      if route.verb =~ request.method && route.path =~ path
        counts[key] += 1
      end
    end

Rspec runs each controller test as a new instance of a class created for each context. This means that module methods like ours can't save instance variables to be seen in another test. For these counts to survive beyond a single controller test, we need to store them somewhere accessible. Using a module attribute will allow access to these counts in multiple tests and access from an after(:all) block to print the results.

Putting it all together we have:

    # spec/suppoer/controller_checking.rb:
    module ControllerChecking
      mattr_accessor :counts
      self.counts = {}

      def process(*)
        begin
          super
        ensure
          # Record controller hit even in the case of an exception. Perhaps the exception is expected.
          path = request.fullpath.split("?").first
          parameters = request.env["action_dispatch.request.parameters"] # will be nil on bad route

          if parameters.present?
            counts = ControllerChecking.counts
            Rails.application.routes.routes.select { |route|
              route.defaults[:controller] == parameters[:controller] &&
                  route.defaults[:action] == parameters[:action]
            }.each do |route|
              verb = route.verb.source.gsub(/[$^]/, '') # convert Regexp back to clean string
              key = "#{route.defaults[:controller]}##{route.defaults[:action]}  #{verb}  #{route.path.spec}"
              # eg: "blogs#show  GET  /blogs/:blog_id/posts/:id"
              counts[key] ||= 0
              if route.verb =~ request.method && route.path =~ path
                counts[key] += 1
              end
            end
          end
        end
      end
    end

    # and in a controller spec:

    describe MyController do
      include ControllerChecking
      before :all do
        ControllerChecking.counts = {}
      end
      after :all do
        puts "-----"
        ControllerChecking.counts.each_pair { |key, count| puts "%2d <-- #{key}" % [count] }
        puts "-----"
      end

      describe "GET index" do
        before { get :index }
        it { should be_success } # and so on
      end
    end

And after running tests in this controller, you should see a nice output, something like:

    -----
     1 <-- posts#index  GET  /posts(.:format)
     0 <-- posts#index  GET  /users/:user_id/posts(.:format)
     0 <-- posts#index  GET  /blogs/:blog_id/posts(.:format)
     0 <-- posts#index  GET  /blogs/:blog_id/users/:user_id/posts(.:format)
    -----

Enjoy,
Matt.

No comments:

Post a Comment