svs.io

← Back to blog

Painless Controller Testing In Rails

There seems to be reasonable support for the opinion that controllers do not require testing and that integration tests or acceptance tests are sufficient testing for controllers. I think in large part this opinion arises because controllers are hard to test. In this post, I’d like to share a technique I use for painless controller testing, but before that we can try and answer the question whether acceptance/integration tests are sufficient tests for the controllers as well.

In my opinion, what an application does and the visual representation of those actions are two completely different things. To give a trivial example, when testing the create method using capybara one might say page.should have_text("Item was saved"). Is this a sufficient test? Could there be a situation where the controller thinks an item has been saved but it hasn’t actually? I think so. A much more comprehensive test would be to specify expect { post :create, {:item => {...}}}.to change(Item, :count).by(1) in a controller test. Secondly, if tomorrow you get an edgy designer who says hang on, this message is really boring. We want the flash message to say “Awesomesauce! You’re bloody brilliant you are!” then you have no way of knowing that your controller is ok but your views are out of date. Coupling the UI with the functionality is unnecessary complexity, which brings us neatly to our next point.

Controllers are the API of your application. They are how your application talks to the outside world and is a major piece of your architecture. This code does some very specific things and it must be under test coverage. Additionally, testing is less about catching bugs than it is about coaxing a solid architecture out of your various constraints and requirements. Test Driven Design really works and the payoffs are so fundamental that I for one am never going back to the old way of doing things. I’m not saying TDD is the silver bullet of software development, just that it is probably the easiest way to come up the curve when it comes to thinking architecturally. My previous articles on Test Driven Design and State Machines all came out of rigorously applying TDD and really taking a step back to address the issues that were making my tests painful. The payoff is huge - code that is very modular and easy to maintain.

So I took the same approach to controller testing. The first thing to do is to take a naive approach to controller testing. If you look at the controller spec file that RSpec generates, it does a pretty good job of enumerating the responsibilites of a controller. Let’s quickly go over these

  • variable assignment - does the controller gather the correct data from various places?
  • message expectations - does the controller call the correct methods with the correct parameters on the correct object?
  • response handling - does the controller render the correct template or redirect to the correct URL?

Here’s the rspec output

ItemsController
DELETE destroy
destroys the requested item
redirects to the items list
GET show
assigns the requested item as @item
GET new
assigns a new item as @item
POST create
with valid params
redirects to the created item
assigns a newly created item as @item
creates a new Item
with invalid params
assigns a newly created but unsaved item as @item
re-renders the 'new' template
PUT update
with valid params
redirects to the item
assigns the requested item as @item
updates the requested item
with invalid params
assigns the item as @item
re-renders the 'edit' template
GET edit
assigns the requested item as @item
GET index
assigns all items as @items
Finished in 0.3961 seconds
16 examples, 0 failures
view raw gistfile1.txt hosted with ❤ by GitHub

These are the classic responsibilites of the controller. Technically, it is not the responsibility of the controller to worry about the side-effects of the method calls it makes. However, for some issues there is no better place to put this spec and I personally prefer this functionality to be tested in the controller. RSpec agrees and does the same -

  • side effects - does the controller add/delete items as requested? Specifically you will often see a expect { ... }.to change(Foo, :count).by(1).

In a simpler world, this would be enough to decide if the controller is working as specified. For better or for worse, our world is not so simple and we have additional responsibilites in our controller, specifically authentication and authorization. Deciding whether to allow a particular action to a particular user is responsibility of the controller and this adds a lot of pain to our tests. Now for each user role, we need to have one set of tests. Already we start to feel the pain but we want to delineate the pain more precisely so we plough through it, ending up with an RSpec output that looks like this.

ReceiptsController
unauthenticated user
should not be able to access index page
POST create
does not create a new Receipt
customer user
GET index
assigns all users documents as @documents
searches properly
gets the right document
GET show
assigns the requested document as @document
GET new
assigns a new document as @document
GET edit
assigns the requested document as @document
POST create
with valid params
creates a new Receipt
assigns a newly created document as @document
redirects to the show page of the created document
with attached scan
assigns the new receipt the scan
marks the scan as attached
also does vice versa
puts the receipt in state editing
with invalid params
assigns a newly created but unsaved document as @document
re-renders the 'new' template
hacking attempt
does not create a new Receipt for other user
PUT update
with valid params
updates the requested document
assigns the requested document as @document
logs a state transition
redirects to the document
DELETE destroy
marks the receipt as deleted
logs to the audit trail
tagger user
GET index
redirects to /
GET
show
assigns the requested document as @document if it is scanned
assigns the requested document as @document if it is incomplete
assigns the requested document as @document if it is checker rejected
does not assign the requested document as @document if it is complete
does not assign the requested document as @document if it is checker_accepted
does not assign the requested document as @document if it is user_rejected
does not assign the requested document as @document if it is user_accepted
new
assigns a new document as @document
edit
assigns the requested document as @document if it is scanned
assigns the requested document as @document if it is incomplete
assigns the requested document as @document if it is checker rejected
does not assign the requested document as @document if it is complete
does not assign the requested document as @document if it is checker_accepted
does not assign the requested document as @document if it is user_rejected
does not assign the requested document as @document if it is user_accepted
PUT update
with valid params
updates the requested document
assigns the requested document as @document
redirects to the next scans index
POST create
with valid params
creates a new Receipt
redirects to show after creation
with invalid params
does not assign a newly created but unsaved document as @document
redirects to /
Finished in 6.83 seconds
47 examples, 0 failures
view raw painful_rspec hosted with ❤ by GitHub

OK, this is just for two user roles and we already have a spec file of 400 lines. Clearly, an unsustainable situation!. A spec should be easy to read and comprehensible at a glance. We need to refactor. Ploughing through the pain of our repetitive tests we learned a few things about our controller. The first thing we learned is that the only method that behaves differently from the others is the index method. The reason for this is that when a user is not authorised to edit or update some object, a CanCan::Unauthorized is raised. It is only for the index action that we might want to return different objects based on access control. What this means is that we only need to login with credentials to test correct assignment of data for the index method. And we do so with a small shared example group like so

shared_examples "authorised index" do |user, items|
describe "index" do
before :each do
@request.env["devise.mapping"] = Devise.mappings[:user]
sign_in user
get :index
end
it "should assign proper items" do
assigns[:items].to_a.should =~ items
end
it "should respond ok" do
response.should be_ok
end
end
end
describe ItemsController
context "authorised" do
describe "index" do
Item.all.destroy!
@u = FactoryGirl.create(:user)
@admin = FactoryGirl.create(:admin)
@tagger = FactoryGirl.create(:tagger)
@i = FactoryGirl.create(:item, :user => @u)
@i2 = FactoryGirl.create(:item, :taggable => true)
it_behaves_like "authorised index", @u, [@i]
it_behaves_like "authorised index", @admin, Item.all.to_a
it_behaves_like "authorised index", @tagger, [@i2]
end
end
end

Voila, we have a one-liner to check data assignment for any given user role.

Now, we move on to testing the various other actions. Since the remaining actions now only need to know whether the user is authorised or not, we don’t need to actually login. This is a good place to use a stub because the stubbed functionality is unlikely to change. Here’s a look at the little DSL we might implement to help us keep our tests DRY.

shared_examples "authorised action" do
before :each do
action.call
end
it "should assign proper items" do
if defined?(variable)
variable.each do |k,v|
assigns[k].should v.call
end
end
end
it "should satisfy expectations" do
if defined?(expectations)
expectations.each do |e|
expect(action).to (e.call)
end
end
end
it "should render proper template/ redirect properly" do
response.should redirect_to(redirect_url) if defined?(redirect_url)
response.should render_template(template) if defined?(template)
end
end
describe ItemsController do
describe "authorised"
describe "other actions" do
before :each do
controller.stubs(:current_user => @user)
Ability.any_instance.stubs(:can?).returns(true)
@item = FactoryGirl.create(:item)
end
describe "new" do
it_should_behave_like "authorised action" do
let(:action) { Proc.new {post :new } }
let(:variables) { {:item => Proc.new{be_a_new(Item)}} }
let(:template) { :new }
end
end
describe "show" do
it_should_behave_like "authorised action" do
let(:action) { Proc.new {post :show, {:id => @item.id} } }
let(:variables) { {:item => lambda{ eq @item} } }
let(:template) { :show }
end
end
describe "edit" do
it_should_behave_like "authorised action" do
let(:action) { Proc.new {get :edit, {:id => @item.id} } }
let(:variables) { {:item => lambda{ eq @item} } }
let(:template) { :edit }
end
end
describe "update" do
before :each do
Item.any_instance.expects(:save).returns(true)
end
it_should_behave_like "authorised action" do
let(:action) { Proc.new {put :update, {:id => @item.id, :item => {}} } }
let(:variables) { {:item => lambda{ eq @item} } }
let(:redirect_url) { @item }
end
end
describe "create" do
it_should_behave_like "authorised action" do
let(:action) { Proc.new {post :create, {:item => {:user_id => 1}} } }
let(:variables) { {:item => lambda{ eq @item} } }
let(:redirect_url) { assigns[:item] }
let(:expectations) { [
lambda{ change(Item, :count).by(1)}
]}
end
end
end
end
end

One nice thing about this approach is that it preserves the informative failure messages from RSpec.

ItemsController
unauthorised
does not edit
does not new
does not index
does not show
does not update
authorised
index
behaves like authorised index
index
should assign proper items
should respond ok
behaves like authorised index
index
should assign proper items (FAILED - 1)
should respond ok
behaves like authorised index
index
should assign proper items
should respond ok
other actions
update
it should behave like authorised action
should assign proper items
should satisfy expectations
should render proper template/ redirect properly
show
it should behave like authorised action
should assign proper items
should satisfy expectations
should render proper template/ redirect properly
new
it should behave like authorised action
should assign proper items
should satisfy expectations
should render proper template/ redirect properly
edit
it should behave like authorised action
should assign proper items
should satisfy expectations
should render proper template/ redirect properly
create
it should behave like authorised action
should assign proper items
should satisfy expectations
should render proper template/ redirect properly
Failures:
1) ItemsController authorised index behaves like authorised index index should assign proper items
Failure/Error: assigns[:items].to_a.should =~ items
expected collection contained: [#<Item @id=1 @name="name_1" @taggable=false @user_id=1>, #<Item @id=2 @name="name_2" @taggable=true @user_id=4>]
actual collection contained: [#<Item @id=1 @name="name_1" @taggable=false @user_id=1>, #<Item @id=2 @name="name_2" @taggable=true @user_id=4>, #<Item @id=3 @name="name_3" @taggable=false @user_id=5>]
the extra elements were: [#<Item @id=3 @name="name_3" @taggable=false @user_id=5>]
Shared Example Group: "authorised index" called from ./spec/controllers/items_controller_spec.rb:84
# ./spec/controllers/items_controller_spec.rb:11:in `block (3 levels) in <top (required)>'
Finished in 0.7384 seconds
26 examples, 1 failure
Failed examples:
rspec ./spec/controllers/items_controller_spec.rb:10 # ItemsController authorised index behaves like authorised index index should assign proper items
Randomized with seed 10358
view raw painless hosted with ❤ by GitHub

The whole example including testing authentication, authorisation, message expectations, variable assignment, response handling and side effects is about 80 lines of RSpec with about 40 lines of shared examples.

The whole example is available here: https://github.com/svs/painless_controller_tests/blob/master/spec/controllers/items_controller_spec.rb