Rails testing miniseries part 5: Controller testing
In Rails apps the main intelligence is supposed to live in the model. As a result, testing properly-designed controllers should mostly be just testing that things are glued together correctly; if your app's factored well, it should be simple and quick to do.
Tools
As for model testing, we have a choice of two testing frameworks for controller testing, Test::Unit and RSpec, which I talked about earlier in the series.
Controller testing is covered by Test::Unit “functional tests” or by RSpec “controller specs”.
(Again, the “functional tests” moniker isn't consistent with what most of the software industry uses – to them functional testing is basically black-box testing or acceptance testing.)
Some things to test for
- Invoking the right model methods with the appropriate parameters (use mocking and stubbing!)
- Redirects/renders/flashes/cookies – ie. the postconditions, what the browser is instructed to do
- Optional parameter handling etc. – if you have any optional GET parameters that are supposed to make different things happen
- Security restrictions – more mocking and stubbing, see below
- Other before/after/around filters (test that these do what they should separately, and again mock and stub)
- Routes – not necessary for normal resource routes etc., but good if you have custom routes and definitely worthwhile if you have “fancy” URLs that have actual strings rather than just IDs – make sure there's no surprises with ‘.’s and ‘/’s etc. – or make use of other magic routing goodness
Test the parts
I like to think of the work that controllers do as being broken down into four parts:
- The underlying models
- The filters (before_filters like login_required, superusers_only, find_editable_article etc.)
- Non-public shared code methods used by the action methods (eg. current_user, deny_access)
- The action methods (ie. #show, #create, etc.), which combine together the other three kinds of parts together
In a poorly-factored controller, these will be all jumbled in together in big, messy, non-modular action methods.
When I was brought on to one of the big customer projects I've been working on this year, that's exactly what I found, and since the controllers had literally dozens of bugs, we had to test them comprehensively… which meant we had to test out every combination of things that can go wrong for each method!
This isn't just a giant pain in the ass, it's a time-wasting giant pain in the ass. You'll spend a lot of time writing tests, even if you extract out the common test code to helper methods as much as possible, and the tests will take a long time to run (a whopping 30 minutes for the full suite for this customer!) – which means slow iterations or even worse, people getting impatient and not running the tests.
Now, the controllers have been refactored so that the four parts are clear, and that's a huge win for testing because it means we can just test each piece once. Refactoring the tests to match is cutting down on their size and their execution time, and it's also making them less brittle – less test code needs to be changed when we add or change features.
Points to remember
- Don't duplicate your model tests; you can just make sure that the model is called with the right stuff without retesting what that does – but your controller must be properly factored, no nasty scoping or other model code in the controller that could screw up the model's behaviour
- Stub/mock out things not under test; if you've already tested that the admin_login_required before_filter works, you don't need to retest the admin_login_required functionality each time it's used – you can just test that that callback is called and makes the controller act appropriately (ie. redirect or return an error when it returns false)
Testing the model parts
In Rails apps, unlike Java web apps, the intelligence is supposed to live in the model and the controllers should just be gluing everything together – see Jamis' classic post ‘Skinny Controller, Fat Model’.
This makes models the most important part of the app, so they get tested separately (see the previous article on model testing).
Testing the filter parts
Verifying the preconditions to your controller methods properly – is the user logged in?, are they a superuser?, is the requested asset visible? – should be DRYd up to clean, easy-to-understand before_filters.
You can then test out these filter methods in just the same way that you test model methods – simply call 'em with the right parameters, and make sure that they do the right things (eg. redirect/raise an error/set up an instance variable/do nothing).
Test the non-public method parts
‘Non-public methods’ is my catch-all term for any DRYd-up controller stuff you've got that isn't a filter, current_user and deny_access being my favorite examples.
(‘Non-public’ as you have to make sure that these should always be private or protected so that Rails doesn't see them as controller actions, which users can get to – you don't want people to be able to go to http://awesome.com/photos/current_user and have it call your current_user method, irrespective of whether that would do anything or be any use to them.)
Because you mostly do this sort of thing using before_filters, model methods, or view helpers, there shouldn't be too many of these, and in my experience they usually live in the base application controller class.
Still, if you've got any, they're easy to test – once again, you just call them and make sure they do the right thing. If they live in a base class like ApplicationController and no subclasses interfere with them, you can just test them once, not for every controller.
Moving along…
Testing the action method parts
This is where the nutritious goodness of your controller's actions goes.
However, any everything to do with making up DB queries should be in your models, and all the other shared code should be DRYed up as above.
So what's left to test? Mostly you want to check that parameters are used appropriately, the right instructions are given back to the browser, and any different cases that the controller code handles are applied in the appropriate situations.
For example, the wrote-it-in-a-weekend blogging software I use for this site has a controller whose #show action on that served the page you're looking at now. If you hit an URL that doesn't exist, #show will show you a (lame, unstyled) 404 page.
If I hit it when I'm logged in though, it redirects me to the #new action where the ‘make a new post’ form will have the URL I hit already filled in the form. This means there's two simple cases in the #show method that I need to test for – in this case, simply testing that I've got them the right way around and that the right error is raised (for un-logged-in visitors) or the right parameters are given back in the redirect URL (for logged-in users).
In 2007, the Rails community has fully adopted the RESTful controller idiom, making modern controllers look positively spartan – having set up the before_filters, the action methods are often just a model call and a respond_to block. So all we need to test is that the right call is made, which brings us to…
Testing the combination
No matter how good your tests for each of the individual calls that make up a controller action, you need at least some coverage that the whole thing hands together correctly.
This generally needs to be without any mocking or stubbing, because in reality there's no way to be completely certain that your mocking and stubbing is completely correct (including type assumptions, for example) – even if it is when you first write the code, another dev changing things later might not notice that they need to update a mock in order to make a test fail so that they then discover they need to change the controller code.
I recommend that you at least do one of these tests for the main happy path, and you may also wish to do it for one error case to check how errors are handled.
You can either handle this in another controller test, or you can move on to integration testing or full-stack testing, which I'll cover in later posts in the series.