Basic Controller Specs
The Skinny One
Rails developers have this consensus: skinny controllers, fat models. This consensus sometimes leads to less attention to controllers, including in testing coverage.
Why Test Controllers?
In our course, we will cover test for controllers too. There are several good reasons for this:
- Controllers are classes with methods too, and they're important classes so you better test them
- Controller specs can often be written more quickly than their integration spec counterparts
- Controller specs usually run more quickly than integration specs too
Why Not Test Controllers?
However, you should keep in mind that there cases been made against testing controllers too:
- Controllers should be skinny, so skinny that some suggests testing them is fruitless
- While controller specs are faster than feature specs, they are still slower than model specs
- One feature spec, often time, can accomplish the work of multiple controller specs
Preparation
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.
Controller Testing Basics
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
Similarities to Model Specs
As you may have noticed, there are several similarities to model specs we write in the previous lessons. The similarities are:
- The description of example is written in explicit and active sentence
- The example only expects one thing, that is after the post request is processed, a redirect should be returned to the browser
- A factory generates test data to be passed to the controller method
New Things
However, there are several new things we see in our controller specs:
- The basic syntax of a controller spec are: its HTTP method (post), controller method (create), and optionally parameters being passed to the method
- The use of attributes_for call to FactoryGirl; Also important to remember that method "attributes_for" generates a hash of attributes, not an object
Organizing a Controller Spec
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
Index
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
Show
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
New
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
Edit
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
Create
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
Update
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
Destroy
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
Commit!
As usual, don't forget to commit.
Preparing Test Data
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
Testing GET Requests
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
What We Just Did (1)
We created two specs:
- The first spec checks if a persisted food is found by the controller and properly assigned to the specified instance variable (@food) using "assigns" method from "rails-controller-testing" gem
- The second checks if the response sent from the controller back up the chain toward the browser will be rendered using the show.html.erb template
What We Just Did (2)
From the two specs, some several key concepts that we learned are:
- The basic DSL for interacting with controller methods: each HTTP request has its own method ("get") which expects the controller method name as a symbol (":show"), followed by any params ("id: food")
- Variables instantiated by the controller method can be evaluated using "assigns(:variable_name)"
- The finished product from controller can be evaluated through "response"
Testing Index (1)
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
Testing Index (2)
It fails! Why?
Testing Index (3)
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
Testing Index (4)
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?
Testing Index (5)
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
Testing Index (6)
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
Commit!
It's been a while since we last commit our progress. Now is a good time for that.
Testing New
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
Testing Edit (1)
Considering we have made specs for "show" and "new" method, can you guess how to create specs for "edit" method?
Testing Edit (2)
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
Testing POST Requests (1)
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
Testing POST Requests (2)
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
Testing PATCH Requests (1)
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
Testing PATCH Requests (2)
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
Testing DELETE Requests
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
Commit!
We finally get all green in our rspec! We can commit our progress so far.
What We Just Did
A lot, actually! Today you have:
- Learned how to build Model specs
- Learned how to use context within a spec
- Learned how to use FactoryGirl to feed test data
- Learned how to use Faker to generate names
- Learned how to write tests for Controller with basic CRUD
Basic Controller Specs
By qblfrb
Basic Controller Specs
- 276