Rails developers have this consensus: skinny controllers, fat models. This consensus sometimes leads to less attention to controllers, including in testing coverage.
In our course, we will cover test for controllers too. There are several good reasons for this:
However, you should keep in mind that there cases been made against testing controllers too:
As per Rails 5, some methods that we will use in our controller specs are now shipped as a different gem. We need to add it to our Gemfile.
group :development, :test do
# -cut-
gem 'rails-controller-testing'
# -cut-
end
Don't forget to bundle install.
A typical controller spec is based off of a single action and, optionally, any parameters passed to it. Example:
# You don't need to write this to your source code,
# for now this is just an example that we will further explore later
it "redirects to the home page upon save" do
post :create, food: FactoryGirl.attributes_for(:food)
expect(response).to redirect_to root_url
end
As you may have noticed, there are several similarities to model specs we write in the previous lessons. The similarities are:
However, there are several new things we see in our controller specs:
Now let's start writing our controller spec. We begin with our FoodsController. Can you guess what methods are we going to test?
require 'rails_helper'
describe FoodsController do
end
For "index" method, we want to create two contexts: one with "letter" params and one without "letter" params.
require 'rails_helper'
describe FoodsController do
describe 'GET #index' do
context 'with params[:letter]' do
it "populates an array of foods starting with the letter"
it "renders the :index template"
end
context 'without params[:letter]' do
it "populates an array of all foods"
it "renders the :index template"
end
end
end
For "show" method, we want to make sure the controller shows the correct food data.
require 'rails_helper'
describe FoodsController do
# -cut-
describe 'GET #show' do
it "assigns the requested food to @food"
it "renders the :show template"
end
end
For "new" method, we want to make sure the controller assigns a new empty instance of Food model.
require 'rails_helper'
describe FoodsController do
# -cut-
describe 'GET #new' do
it "assigns a new Food to @food"
it "renders the :new template"
end
end
For "edit" method, we want to make sure the controller assigns a the correct instance of Food to @food.
require 'rails_helper'
describe FoodsController do
# -cut-
describe 'GET #edit' do
it "assigns the requested food to @food"
it "renders the :edit template"
end
end
For "create" method, we want to test for two contexts: one with valid attributes and another one with invalid attributes.
require 'rails_helper'
describe FoodsController do
# -cut-
describe 'POST #create' do
context "with valid attributes" do
it "saves the new food in the database"
it "redirects to foods#show"
end
context "with invalid attributes" do
it "does not save the new food in the database"
it "re-renders the :new template"
end
end
end
For "update" method, we want to test for two contexts: one with valid attributes and another one with invalid attributes.
require 'rails_helper'
describe FoodsController do
# -cut-
describe 'PATCH #update' do
context "with valid attributes" do
it "locates the requested @food"
it "changes @food's attributes"
it "redirects to the food"
end
context "with invalid attributes" do
it "does not update the food in the database"
it "re-renders the :edit template"
end
end
end
For "destroy" method, we want to make sure that the deleted food is actually deleted from our database.
require 'rails_helper'
describe FoodsController do
# -cut-
describe 'DELETE #destroy' do
it "deletes the food from the database"
it "redirects to foods#index"
end
end
As usual, don't forget to commit.
Next, we prepare both valid and invalid test data using FactoryGirl.
FactoryGirl.define do
factory :food do
name { Faker::Food.dish }
description { Faker::Food.ingredient }
price 10000.0
end
factory :invalid_food, parent: :food do
name nil
description nil
price 10000.0
end
end
We start from the easiest methods to test: the ones with get requests. They're "index", "show", "new", and "edit".
describe FoodsController do
# -cut-
describe 'GET #show' do
it "assigns the requested food to @food" do
food = create(:food)
get :show, params: { id: food }
expect(assigns(:food)).to eq food
end
it "renders the :show template" do
food = create(:food)
get :show, params: { id: food }
expect(response).to render_template :show
end
end
# -cut-
end
We created two specs:
From the two specs, some several key concepts that we learned are:
For "index" controller, this is how we test context with params:
describe FoodsController do
describe 'GET #index' do
context 'with params[:letter]' do
it "populates an array of foods starting with the letter" do
nasi_uduk = create(:food, name: "Nasi Uduk")
kerak_telor = create(:food, name: "Kelar Telor")
get :index, params: { letter: 'N' }
expect(assigns(:foods)).to match_array([nasi_uduk])
end
it "renders the :index template" do
get :index, params: { letter: 'N' }
expect(response).to render_template :index
end
end
context 'without params[:letter]' do
it "populates an array of all foods"
it "renders the :index template"
end
end
# -cut-
end
It fails! Why?
Because we have not modify our controller to actually receive "letter" parameter. We have modified our model before, but not our controller. This is why we need to test our controller too.
class FoodsController < ApplicationController
# -cut-
# GET /foods
# GET /foods.json
def index
@foods = Food.by_letter(params[:letter])
end
# -cut-
end
Now try to make your own test specs for "index" method without "letter" parameter!
Don't worry if it fails when you execute it, though. It is expected. But can you explain why?
This is how your test specs for "index" method without "letter" parameter should look like:
describe FoodsController do
describe 'GET #index' do
# -cut-
context 'without params[:letter]' do
it "populates an array of all foods" do
nasi_uduk = create(:food, name: "Nasi Uduk")
kerak_telor = create(:food, name: "Kelar Telor")
get :index
expect(assigns(:foods)).to match_array([nasi_uduk, kerak_telor])
end
it "renders the :index template" do
get :index
expect(response).to render_template :index
end
end
end
# -cut-
end
It fails because previously we just modify our "index" controller to just enough to pass specs for "with params[:letter]" context. Now we need to modify it again.
class FoodsController < ApplicationController
# -cut-
# GET /foods
# GET /foods.json
def index
@foods = params[:letter].nil? ? Food.all : Food.by_letter(params[:letter])
end
# -cut-
end
It's been a while since we last commit our progress. Now is a good time for that.
Defining test specs for "new" method is pretty straightforward:
describe FoodsController do
# -cut-
describe 'GET #new' do
it "assigns a new Food to @food" do
get :new
expect(assigns(:food)).to be_a_new(Food)
end
it "renders the :new template" do
get :new
expect(:response).to render_template :new
end
end
# -cut-
end
Considering we have made specs for "show" and "new" method, can you guess how to create specs for "edit" method?
Your "edit" specs should look like this:
describe FoodsController do
# -cut-
describe 'GET #edit' do
it "assigns the requested food to @food" do
food = create(:food)
get :edit, params: { id: food }
expect(assigns(:food)).to eq food
end
it "renders the :edit template" do
food = create(:food)
get :edit, params: { id: food }
expect(response).to render_template :edit
end
end
# -cut-
end
To test POST requests, we use FactoryGirl's method called "attributes_for".
describe FoodsController do
# -cut-
describe 'POST #create' do
context "with valid attributes" do
it "saves the new food in the database" do
expect{
post :create, params: { food: attributes_for(:food) }
}.to change(Food, :count).by(1)
end
it "redirects to foods#show" do
post :create, params: { food: attributes_for(:food) }
expect(response).to redirect_to(food_path(assigns[:food]))
end
end
# -cut-
end
# -cut-
end
As for "create" method with invalid "food" attributes, we use slightly different specs:
describe FoodsController do
# -cut-
describe 'POST #create' do
# -cut-
context "with invalid attributes" do
it "does not save the new food in the database" do
expect{
post :create, params: { food: attributes_for(:invalid_food) }
}.not_to change(Food, :count)
end
it "re-renders the :new template" do
post :create, params: { food: attributes_for(:invalid_food) }
expect(response).to render_template :new
end
end
end
# -cut-
end
To test PATCH requests, we still use FactoryGirl's method called "attributes_for" with only slight different on the specs.
describe FoodsController do
describe 'PATCH #update' do
before :each do
@food = create(:food)
end
context "with valid attributes" do
it "locates the requested @food" do
patch :update, params: { id: @food, food: attributes_for(:food) }
expect(assigns(:food)).to eq @food
end
it "changes @food's attributes" do
patch :update, params: { id: @food, food: attributes_for(:food, name: 'Nasi Uduk') }
@food.reload
expect(@food.name).to eq('Nasi Uduk')
end
it "redirects to the food" do
patch :update, params: { id: @food, food: attributes_for(:food) }
expect(response).to redirect_to @food
end
end
end
end
Now for PATCH requests, with invalid attributes:
describe FoodsController do
describe 'PATCH #update' do
# -cut-
context "with invalid attributes" do
it "does not update the food in the database" do
patch :update, params: { id: @food, food: attributes_for(:food, name: 'Nasi Uduk', description: nil) }
@food.reload
expect(@food.name).not_to eq('Nasi Uduk')
end
it "re-renders the :edit template" do
patch :update, params: { id: @food, food: attributes_for(:invalid_food) }
expect(response).to render_template :edit
end
end
end
end
Lastly, to test DELETE requests, we can use:
describe FoodsController do
# -cut-
describe 'DELETE #destroy' do
before :each do
@food = create(:food)
end
it "deletes the food from the database" do
expect{
delete :destroy, params: { id: @food }
}.to change(Food, :count).by(-1)
end
it "redirects to foods#index" do
delete :destroy, params: { id: @food }
expect(response).to redirect_to foods_url
end
end
end
We finally get all green in our rspec! We can commit our progress so far.
A lot, actually! Today you have: