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