How I Test Controllers
I’ve been trying to improve the specs I write lately. My method before was mostly copying and pasting from my past projects. Somewhere way back I adapted them from an early version of Michael Hartl’s Rails Tutorial, making various modifications over the years.
Recently I decided to scrap my specs and start over from scratch. Since my controller specs were particularly ugly, I decided I’d start with them.
I wanted to share what I learned, so here goes. In true test-driven fashion, we’ll write a little one-controller app, specs first. Our app will be a list of favorite books. We’ll start with the most basic skeleton:
require 'spec_helper'
describe BooksController do
describe '#index' do
end
describe '#show' do
end
describe '#new' do
end
describe '#edit' do
end
describe '#create' do
end
describe '#update' do
end
describe '#destroy' do
end
end
Because this app is so simple, there will be nothing at all interesting in our model, and thus nothing to test. We’ll trust ActiveRecord to do its job since that code is already tested ;) Here’s our model:
# == Schema Information
#
# Table name: books
#
# id :integer not null, primary key
# title :string(255)
# author :string(255)
# description :text
# created_at :datetime
# updated_at :datetime
#
class Book < ActiveRecord::Base
end
Now let’s start with index. What do we expect an index action to do?
- We need to get an array of all books from ActiveRecord via the model.
- We need to assign that array to a variable for the view
- We need to render the index template
- We need to indicate to the browser that the request was successful (HTTP status 200)
- We don’t need to set any flash messages
- There’s really only one path to test. The only abnormal instance would be an empty array of books, but the controller doesn’t really know or care whether this happens – just the model and the view.
In the past I would have done a Factory Girl create in a before block to test getting the array. But the controller isn’t really responsible for getting the ActiveRecord object, only asking the the model for it and passing the result on to the view. Besides, we should probably write integration specs to make sure everything works together, so we’re going to test that anyway. That’s why these days I like to use mocks in testing my controllers.
Let’s go ahead and create a Factory Girl factory anyway. We can use attributes_for
as a convenient way to get our params hash, and the factory will be useful later for our integration specs and possibly model if we add anything interesting like validations.
FactoryGirl.define do
factory :book do
title 'Pride and Prejudice'
author 'Jane Austen'
description '...'
end
end
Time to write our first failing spec
let(:book) { mock_model(Book) }
let(:book_attributes) { FactoryGirl.attributes_for(:book).stringify_keys }
describe '#index' do
before do
expect(Book).to receive(:all).once.and_return([book])
get :index
end
it "does something" do
end
end
And we get our failure: Book didn’t receive all. Let’s modify the index action in the controller:
def index
Book.all
end
And it passes. Now let’s write our first real spec.
describe '#index' do
before do
expect(Book).to receive(:all).once.and_return([book])
get :index
end
it 'assigns the instance variable' do
expect(assigns(:books)).to eq([book])
end
end
Of course it fails, because while we called all on Book to get our first expectation to pass, we didn’t assign it to a variable for the view. Let’s fix that.
def index
@books = Book.all
end
And it passes. On to our next spec. It should respond with success. Because this is the normal response for rails, let’s set ourselves up for failure.
def index
@books = Book.all
render status: 404
end
And write our spec:
it { should respond_with(:success) }
Failure. Let’s fix it and set up our next failure.
def index
@books = Book.all
render layout: false
end
Success.
it { should render_with_layout :application }
Failure.
def index
@books = Book.all
render layout: 'application', text: ''
end
Success.
it { should render_template :index }
Failure.
def index
@books = Book.all
end
One could argue that it seems excessive to write code to make such simple specs fail, and I’ll readily admit I don’t always do this. But this is what the rhythm of TDD should feel like, and it’s not completely safe to trust specs you haven’t seen fail.
#show, #new, and #edit will be very similar – there’s only one path and they simply render a view. I’ll include the full code at the end.
The next interesting challenge is *#create. Let’s take a crack at that.
What do we expect a create action to do?
- We need to instantiate a new Book with the params we’re given
- We need to assign the object to a variable for the view in case we need to display it
- We need to ask the book to persist itself
- If the save reports success, we want to:
- Set a flash message indicating success
- Redirect to index
- Because it’s a redirect, index is responsible for anything that happens after that. We’ve already tested that.
- If the save fails, we want to:
- Render the new template
- Indicate to the browser that the request was successful (HTTP status 200) – despite the fact that the save was not
- We don’t need to display any flash messages
def create
render :new
end
describe '#create' do
before do
expect(Book).to receive(:new).with(book_attributes).once.and_return(book)
post :create, book: book_attributes
end
it "does something" do
end
end
Failure – Book didn’t receive new
def create
Book.new(book_params)
render :new
end
#...
private
def book_params
params.require(:book).permit(:title, :author, :description)
end
Success. Speaking of which, it’s time to branch our specs for success vs failure.
describe '#create' do
before do
expect(Book).to receive(:new).with(book_attributes).once.and_return(book)
end
context 'success' do
before do
expect(book).to receive(:save).once.and_return(true)
post :create, book: book_attributes
end
it "does something" do
end
end
context 'failure' do
end
end
It fails.
def create
book = Book.new(book_params)
book.save
render :new
end
It passes.
context 'success' do
before do
expect(book).to receive(:save).once.and_return(true)
post :create, book: book_attributes
end
it { should redirect_to books_url }
end
It fails.
def create
book = Book.new(book_params)
book.save
redirect_to books_path
end
It passes.
context 'success' do
before do
expect(book).to receive(:save).once.and_return(true)
post :create, book: book_attributes
end
it { should set_the_flash.to('Book was successfully created.') }
it { should redirect_to books_url }
end
It fails.
def create
book = Book.new(book_params)
book.save
flash[:notice] = 'Book was successfully created.'
redirect_to books_path
end
It passes. The failure path behaves virtually identically to the new action. We’ll look at a way to exploit this and other similarities to DRY up our specs in a follow-up post on shared examples. For now, notice that book is a local variable. This allows us to call save while still setting up our spec to check whether the book is assigned to an instance variable for failure.
Here is the final create action and corresponding spec:
def create
@book = Book.new(book_params)
if @book.save
flash[:notice] = 'Book was successfully created.'
redirect_to books_path
else
render :new
end
end
describe '#create' do
before do
expect(Book).to receive(:new).with(book_attributes).once.and_return(book)
end
context 'success' do
before do
expect(book).to receive(:save).once.and_return(true)
post :create, book: book_attributes
end
it { should set_the_flash.to('Book was successfully created.') }
it { should redirect_to books_url }
end
context 'failure' do
before do
expect(book).to receive(:save).once.and_return(false)
post :create, book: book_attributes
end
it { should respond_with(:success) }
it { should render_with_layout :application }
it { should render_template :new }
it 'assigns the instance variable' do
expect(assigns(:book)).to eq(book)
end
end
end
So what are the takeaways?
- Write specs that test the thing you’re testing. Use mocks. This makes tests easier to write, easier to read, and super fast. On my machine, 29 specs run in less than two tenths of a second without Spork.
- Describe the behavior of the thing you’re testing. Do this before you write your specs. When you’re done, your specs are documentation. Try rspec spec –format documentation
- Follow the rhythm of TDD: red, green, refactor. For easy stuff like this, refactoring might not be necessary.
It should go without saying, but these are just guidelines. Fast tests are a means, not an end. There are cases where mocking becomes awkward. TDD is a tool, not a dogma.
I’ve posted the finished code on GitHub.
I hope this is helpful to others, and I welcome feedback.