Home Rails & RSpec: Testing CRUD actions with shared examples
Reply: 3

Rails & RSpec: Testing CRUD actions with shared examples

BrunoFacca
1#
BrunoFacca Published in 2017-07-06 19:44:47Z

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). I have tried to use the model name to infer the factory name and create the test data within the share examples. It works well to create the record and records variables. However, some models require the presence of associations and FactoryGirl.attributes_for does not create associated records, so validation fails. So, valid_attributes are created differently for different models. The only (likely bad) way I could think of creating valid_attributes within shared examples is to pass a string containing the code used to create the attributes and evaluate it (eval) within the shared examples
  • The tests that assert redirection use eval to call Rails' route/path helpers. Different controllers in this app have different redirect behaviors. After creating or updating a record, some controllers redirect to the #show action, others to #index. The problem is that when expecting a redirect to #show, AFAIK, we have to know the record ID in order to build the expected URL. And we don't know the record ID within the controller spec. We only know it within the shared examples. So how can we pass an expected redirect URL from the controller spec to the shared example if we do not yet know what that URL is (because we don't know the record ID)?

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) { FactoryGirl.attributes_for(:quote, quote: "New quote") }
  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
engineersmnky
2#
engineersmnky Reply to 2017-07-12 13:37:09Z

Probably not an answer but too long for a comment:

First of all you can wrap all of those in a shared_examples_for block e.g.

shared_examples_for 'a CRUD Controller' do 
  context "GET #index" do
    it "requires login" do
      sign_out current_user
      get :index
      expect(response).to require_login
    end
   ####
  end
  context "GET #show" do
    it "requires login" do
      sign_out current_user
      get :show, id: record
      expect(response).to require_login
    end
   ####
  end
end

Secondly You can have shared examples inside shared examples to the above can be

shared_examples_for 'a CRUD Controller' do 
  shared_examples_for 'authenticatable' do |view:,params:{}|
    it "requires login" do
      sign_out current_user
      get view, **params
      expect(response).to require_login
    end
  end

  context "GET #index" do
   it_behaves_like 'authenticatable', view: :index 
   ####
  end
  context "GET #show" do
   it_behaves_like 'authenticatable', view: :show, id: record
   ####
  end
end

Third you can assign variables inside a it_behaves_like block eg.

RSpec.describe QuotesController, :focus, :type => :controller do
  login_admin
  it_behaves_like 'a CRUD Controller' do  
    let(:model) { Quote }
    let(:record) { FactoryGirl.create(:quote) }
    let(:records) { FactoryGirl.create_pair(:quote) }
    let(:valid_attributes) { FactoryGirl.attributes_for(:quote, quote: "New quote") }
    let(:invalid_attributes) { valid_attributes.update(quote: nil) }
  end
end

Fourth this too can be simplified

shared_examples_for 'a CRUD Controller' do |model:| 
  singular,plural = 2.times.map { |n| model.name.pluralize(n).underscore.to_sym }
  let(:record) { FactoryGirl.create(singular)
  let(:records) {FactoryGirl.create_pair(singular) }
  let(:valid_attributes) do 
    # build should create the nested associations correctly as long 
    # as your factories are right
    FactoryGirl.build(singular).attributes.delete_if do |k,_| 
      # this is because ActiveRecord#attributes contains columns 
      # you don't want to be considered updateable
      ["id","created_at","updated_at"].include?(k)
    end 
  end 
  let(:invalid_attributes) do 
    # create an :invalid trait in your factory so that 
    # you don't have to worry about the model
    FactoryGirl.build(singular, :invalid).attributes.delete_if do |k,_| 
      ["id","created_at","updated_at"].include?(k)
    end 
  end 
  ####
end

RSpec.describe QuotesController, :focus, :type => :controller do
  login_admin
  it_behaves_like 'a CRUD Controller', model: Quote
end

Finally you are going to find that using a memoized let! will help drastically since you are creating an extraordinary amount of records in those tests as it stands now. This will degrade performance drastically and if you get to a model that has certain globally unique attributes your tests will fail everywhere.

Hopefully this helps start pointing you in the right direction

Update to control testing actions

shared_examples_for 'a CRUD Controller' do |model:|
  accessible_method = ->(meth) { public_methods.include?(meth) }

  context "GET #index", if: controller.method_defined?(:index) do
    it_behaves_like 'authenticatable', view: :index 
    ####
  end
  context "GET #show", if: controller.method_defined?(:show) do
    it_behaves_like 'authenticatable', view: :show, id: record
    ####
  end 
end
Simple Lime
3#
Simple Lime Reply to 2017-07-06 20:44:01Z

For the let blocks, does it not work if you pass in the model as a parameter to the shared example like you do with the redirect_path_helper?

include_examples "GET #index", Quote

and then in your shared_example you can use the record_name method to create record and records from FactoryGirl and generate valid_attributes and invalid_attributes (you could create an :invalid_quote factory as well for invalid attributes, not sure if that's considered a good practice/idea with FactoryGirl though) from there.

For the second problem, you don't need to use the named route helpers, url_for(controller: :quote) and url_for(@quote) should both work.

BrunoFacca
4#
BrunoFacca Reply to 2017-07-13 19:35:32Z

Here is the improved code (based on engineersmnky's suggestions). Any suggestions for further improvements are welcome.

Controller spec:

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

RSpec.describe QuotesController, :type => :controller do
  it_behaves_like "a CRUD controller",
                  model: Quote,
                  create_redirect_path_helper: "quote_path(assigns(:quote))",
                  update_redirect_path_helper: "quote_url",
                  delete_redirect_path_helper: "quotes_url"
end

Shared examples:

# spec/support/shared_examples/controller_restful_actions.rb
RSpec.shared_examples "a CRUD controller" do |model:,
                                              create_redirect_path_helper:,
                                              update_redirect_path_helper:,
                                              delete_redirect_path_helper:| 

  def self.controller_has_action?(action)
    described_class.action_methods.include?(action.to_s)
  end

  resource_singular = model.name.underscore.to_sym
  resource_plural = model.name.pluralize.underscore.to_sym

  before(:each) { login_admin }

  let(:record) { FactoryGirl.create(resource_singular) }
  let(:records) { FactoryGirl.create_pair(resource_singular) }
  # Models that validate the presence of associated records require some
  # hacking in the factory to include associations in the attributes_for output.
  let(:valid_attributes) { FactoryGirl.attributes_for(resource_singular) }
  # All factories must have a trait called :invalid
  let(:invalid_attributes) do
    FactoryGirl.attributes_for(resource_singular, :invalid)
  end

  describe "GET #index", if: controller_has_action?(:index) do
    it "requires login" do
      logout
      get :index
      expect(response).to require_login_web
    end

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

    it "populates @#{resource_plural} with an array of #{resource_plural}" do
      # Force records to be created before the request.
      records
      get :index
      # Required when testing the User model, or else the user created
      # by the Devise login helper skews the result of this test.
      expected_records = assigns(resource_plural) - [@current_user]
      expect(expected_records).to match_array(records)
    end
  end

  describe "GET #show", if: controller_has_action?(:show) do
    it "requires login" do
      logout
      get :show, id: record
      expect(response).to require_login_web
    end

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

    it "assigns the requested #{resource_singular} to an instance variable" do
      get :show, id: record
      expect(assigns(resource_singular)).to eq(record)
    end
  end

  describe "GET #new", if: controller_has_action?(:new) do
    it "requires login" do
      logout
      get :new
      expect(response).to require_login_web
    end

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

    it "assigns a new #{resource_singular} to @#{resource_singular}" do
      get :new
      expect(assigns(resource_singular)).to be_a_new(model)
    end
  end

  describe "GET #edit", if: controller_has_action?(:edit) do
    it "requires login" do
      logout
      get :edit, id: record
      expect(response).to require_login_web
    end

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

    it "assigns #{resource_singular} to @#{resource_singular}" do
      get :edit, id: record
      expect(assigns(resource_singular)).to eq(record)
    end
  end

  describe "POST #create", if: controller_has_action?(:create) do
    it "requires login" do
      logout
      post :create, { resource_singular => valid_attributes }
      expect(response).to require_login_web
    end

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

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

      it "assigns the saved #{resource_singular} to @#{resource_singular}" do
        post :create, { resource_singular => valid_attributes }
        expect(assigns(resource_singular)).to be_an_instance_of(model)
        expect(assigns(resource_singular)).to be_persisted
      end

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

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

      it "assigns the unsaved #{resource_singular} to @#{resource_singular}" do
        post :create, { resource_singular => invalid_attributes }
        expect(assigns(resource_singular)).to be_a_new(model)
      end

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

  describe "PATCH #update", if: controller_has_action?(:update) do
    it "requires login" do
      logout
      patch :update, { :id => record,
                       resource_singular => valid_attributes }
      expect(response).to require_login_web
    end

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

    context "with valid attributes" do
      it "updates the requested #{resource_singular}" do
        patch :update, { :id => record,
                         resource_singular => valid_attributes }
        record.reload
        # Required when testing Devise's User model with reconfirmable on
        record.try(:confirm)
        expect(record).to have_attributes(valid_attributes)
      end

      it "assigns the #{resource_singular} to @#{resource_singular}" do
        put :update,  { :id => record,
                        resource_singular => valid_attributes }
        expect(assigns(resource_singular)).to eq(record)
      end

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

    context "with invalid attributes" do
      it "does not update the #{resource_singular}" do
        # Do not attempt to "refactor" the following to any of the following:
        # not_to change { quote }
        # not_to change { quote.attributes }
        # not_to have_attributes(invalid_attributes)
        # None of the above will work. See
        # https://github.com/rspec/rspec-expectations/issues/996#issuecomment-310729685
        expect {
          patch :update, { :id => record,
                           resource_singular => invalid_attributes }
        }.not_to change { record.reload.attributes }
      end

      it "assigns the #{resource_singular} to @#{resource_singular}" do
        patch :update, { :id => record,
                         resource_singular => invalid_attributes }
        expect(assigns(resource_singular)).to eq(record)
      end

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

  describe "DELETE #destroy", if: controller_has_action?(:destroy) do
    it "requires login" do
      logout
      delete :destroy, id: record
      expect(response).to require_login_web
    end

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

    it "deletes the #{resource_singular}" do
      # Force record to be created before the `expect` block.
      # Otherwise, it is both created and deleted INSIDE the block, causing the
      # count not to change.
      record
      expect{
        delete :destroy, id: record
      }.to change(model, :count).by(-1)
    end

    it "redirects to #{delete_redirect_path_helper}" do
      delete :destroy, id: record
      expect(response).to redirect_to(eval(delete_redirect_path_helper))
    end
  end
end
You need to login account before you can post.

About| Privacy statement| Terms of Service| Advertising| Contact us| Help| Sitemap|
Processed in 0.314983 second(s) , Gzip On .

© 2016 Powered by mzan.com design MATCHINFO