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:

  1. Controllers are classes with methods too, and they're important classes so you better test them
  2. Controller specs can often be written more quickly than their integration spec counterparts
  3. 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:

  1. Controllers should be skinny, so skinny that some suggests testing them is fruitless
  2. While controller specs are faster than feature specs, they are still slower than model specs
  3. 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:

  1. The description of example is written in explicit and active sentence
  2. The example only expects one thing, that is after the post request is processed, a redirect should be returned to the browser
  3. 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:

  1. The basic syntax of a controller spec are: its HTTP method (post), controller method (create), and optionally parameters being passed to the method
  2. The use of attributes_for call to Factory Bot; 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 Factory Bot.

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:  

  1. 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
  2. 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: 

  1. 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")
  2. Variables instantiated by the controller method can be evaluated using "assigns(:variable_name)"
  3. 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 Factory Bot'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:

  1. Learned how to build Model specs
  2. Learned how to use context within a spec
  3. Learned how to use FactoryGirl to feed test data
  4. Learned how to use Faker to generate names
  5. Learned how to write tests for Controller with basic CRUD

[Go-Jek x BNCC] Basic Controller Specs

By qblfrb

[Go-Jek x BNCC] Basic Controller Specs

  • 348