jeudi 6 juillet 2017

Testing CRUD actions with shared examples

Testing RESTful actions of multiple Rails controllers with RSpec can generate a lot of code repetition. The following code is my first attempt at using shared examples to DRY things up.

Here is what I don't like about the code, could not find a better way and would like your help to improve:

  • The shared examples require that specific variables are set within let blocks within the controller spec (high coupling).
  • The tests that assert redirection use eval to call Rails' route/path helpers.

Also, please let me know if you spot any additional issues.

The controller spec:

# spec/controllers/quotes_controller_spec.rb
require "rails_helper"

RSpec.describe QuotesController, :focus, :type => :controller do
  login_admin

  let(:model) { Quote }
  let(:record) { FactoryGirl.create(:quote) }
  let(:records) { FactoryGirl.create_pair(:quote) }
  let(:valid_attributes) do
    # The Quote model validates the presence of associated records.
    # attributes_for does no create associated records by default.
    # For the following code to work, the factory must use "strategy: :create".
    FactoryGirl.build(:quote, quote: "New quote").attributes.symbolize_keys.
      except(:id, :publish_status, :slug, :user_id, :created_at, :updated_at).
      merge({ theme_ids: [FactoryGirl.create(:theme).id] })
  end
  let(:invalid_attributes) { valid_attributes.update(quote: nil) }

  include_examples "GET #index"
  include_examples "GET #show"
  include_examples "GET #new"
  include_examples "GET #edit"
  include_examples "POST #create", "quote_path(assigns(:quote))"
  include_examples "PATCH #update", "quote_url"
  include_examples "DELETE #destroy", "quotes_url"
end

The shared examples:

# spec/support/shared_examples/controller_restful_actions.rb
def ivar_name(model, plural: false)
  if plural
    model.name.pluralize.underscore.to_sym
  else
    model.name.underscore.to_sym
  end
end

def record_name(model)
  model.name.underscore.to_sym
end

RSpec.shared_examples "GET #index" do
  describe "GET #index" do
    it "requires login" do
      sign_out current_user
      get :index
      expect(response).to require_login
    end

    it "enforces authorization" do
      get :index
      expect(controller).to enforce_authorization
    end

    it "populates instance variable with an array of records" do
      get :index
      expect(assigns(ivar_name(model, plural: true))).to match_array(records)
    end
  end
end


RSpec.shared_examples "GET #show" do
  describe "GET #show" do

    it "requires login" do
      sign_out current_user
      get :show, id: record
      expect(response).to require_login
    end

    it "enforces authorization" do
      get :show, id: record
      expect(controller).to enforce_authorization
    end

    it "assigns the requested record to an instance variable" do
      get :show, id: record
      expect(assigns(ivar_name(model))).to eq(record)
    end
  end
end


RSpec.shared_examples "GET #new" do
  describe "GET #new" do
    it "requires login" do
      sign_out current_user
      get :new
      expect(response).to require_login
    end

    it "enforces authorization" do
      get :new
      expect(controller).to enforce_authorization
    end

    it "assigns a new record to an instance variable" do
      get :new
      expect(assigns(ivar_name(model))).to be_a_new(model)
    end
  end
end


RSpec.shared_examples "GET #edit" do
  describe "GET #edit" do
    let(:record) { FactoryGirl.create(factory_name(model)) }

    it "requires login" do
      sign_out current_user
      get :edit, id: record
      expect(response).to require_login
    end

    it "enforces authorization" do
      get :edit, id: record
      expect(controller).to enforce_authorization
    end

    it "assigns the requested record to an instance variable" do
      get :edit, id: record
      expect(assigns(ivar_name(model))).to eq(record)
    end
  end
end


RSpec.shared_examples "POST #create" do |redirect_path_helper|
  describe "POST #create" do
    it "requires login" do
      sign_out current_user
      post :create, { record_name(model) => valid_attributes }
      expect(response).to require_login
    end

    it "enforces authorization" do
      post :create, { record_name(model) => valid_attributes }
      expect(controller).to enforce_authorization
    end

    context "with valid attributes" do
      it "saves the new record in the database" do
        expect{
          post :create, { record_name(model) => valid_attributes }
        }.to change(model, :count).by(1)
      end

      it "assigns a newly created but unsaved record to an instance variable" do
        post :create, { record_name(model) => valid_attributes }
        expect(assigns(ivar_name(model))).to be_a(model)
        expect(assigns(ivar_name(model))).to be_persisted
      end

      it "redirects to #{redirect_path_helper}" do
        post :create, { record_name(model) => valid_attributes }
        expect(response).to redirect_to(eval(redirect_path_helper))
      end
    end

    context "with invalid attributes" do
      it "does not save the new record in the database" do
        expect{
          post :create, { record_name(model) => invalid_attributes }
        }.not_to change(model, :count)
      end

      it "assigns a newly created but unsaved record an instance variable" do
        post :create, { record_name(model) => invalid_attributes }
        expect(assigns(ivar_name(model))).to be_a_new(model)
      end

      it "re-renders the :new template" do
        post :create, { record_name(model) => invalid_attributes }
        expect(response).to render_template(:new)
      end
    end
  end
end


RSpec.shared_examples "PATCH #update" do |redirect_path_helper|
  describe "PATCH #update" do
    let(:record) { FactoryGirl.create(factory_name(model)) }

    it "requires login" do
      sign_out current_user
      patch :update, { :id => record, record_name(model) => valid_attributes }
      expect(response).to require_login
    end

    it "enforces authorization" do
      patch :update, { :id => record, record_name(model) => valid_attributes }
      expect(controller).to enforce_authorization
    end

    context "with valid attributes" do
      it "updates the requested record" do
        patch :update, { :id => record, record_name(model) => valid_attributes }
        record.reload
        expect(record).to have_attributes(valid_attributes)
      end

      it "assigns the requested record to an instance variable" do
        put :update,  { :id => record, record_name(model) => valid_attributes }
        expect(assigns(ivar_name(model))).to eq(record)
      end

      it "redirects to #{redirect_path_helper}" do
        patch :update,  { :id => record, record_name(model) => valid_attributes }
        expect(response).to redirect_to(eval(redirect_path_helper))
      end
    end

    context "with invalid attributes" do
      it "does not update the requested record" do
        expect {
          patch :update, { :id => record, record_name(model) => invalid_attributes }
        }.not_to change { record.reload.attributes }
      end

      it "assigns the record to an instance variable" do
        patch :update, { :id => record, record_name(model) => invalid_attributes }
        expect(assigns(ivar_name(model))).to eq(record)
      end

      it "re-renders the :edit template" do
        patch :update, { :id => record, record_name(model) => invalid_attributes }
        expect(response).to render_template(:edit)
      end
    end
  end
end


RSpec.shared_examples "DELETE #destroy" do |redirect_path_helper|
  describe "DELETE #destroy" do
    it "requires login" do
      sign_out current_user
      delete :destroy, id: record
      expect(response).to require_login
    end

    it "enforces authorization" do
      delete :destroy, id: record
      expect(controller).to enforce_authorization
    end

    it "deletes the record" do
      # Records are lazily created. Here we must force its creation.
      record
      expect{
        delete :destroy, id: record
      }.to change(model, :count).by(-1)
    end

    it "redirects to #{redirect_path_helper}" do
      delete :destroy, id: record
      expect(response).to redirect_to(eval(redirect_path_helper))
    end
  end
end

Aucun commentaire:

Enregistrer un commentaire