Rails& RSpec:使用共享示例测试CRUD操作

时间:2017-07-06 19:44:48

标签: ruby-on-rails ruby testing rspec

使用RSpec测试多个Rails控制器的RESTful操作可以生成大量代码重复。以下代码是我第一次尝试使用共享示例来解决问题。

以下是我对代码不喜欢的内容,无法找到更好的方法并希望您的帮助有所改善:

  • 共享示例要求在控制器规范(高耦合)内的let块内设置特定变量。我试图使用模型名称来推断工厂名称并在共享示例中创建测试数据。它可以很好地创建记录和记录变量。但是,某些模型需要存在关联,而FactoryGirl.attributes_for不会创建关联记录,因此验证失败。因此,对于不同的模型,valid_attributes的创建方式不同。我可以想到在共享示例中创建valid_attributes的唯一(可能是坏的)方法是传递一个字符串,其中包含用于创建属性的代码并在共享示例中对其进行评估(eval)
  • 断言重定向的测试使用eval来调用Rails'路线/路径助手。此应用程序中的不同控制器具有不同的重定向行为创建或更新记录后,某些控制器会重定向到#show操作,其他控制器会重定向到#index。问题是当期望重定向到#show,AFAIK时,我们必须知道记录ID才能构建预期的URL。而且我们不知道控制器规范中的记录ID。我们只在共享示例中知道它。那么,如果我们还不知道该URL是什么(因为我们不知道记录ID),我们如何将预期的重定向URL从控制器规范传递到共享示例?

另外,如果您发现任何其他问题,请告诉我。

控制器规格:

# 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

共享示例:

# 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

3 个答案:

答案 0 :(得分:2)

可能不是答案,但评论时间太长了:

首先,您可以将所有这些内容包装在shared_examples_for块中,例如

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

其次你可以在共​​享示例中共享示例,上面的内容可以是

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

第三,您可以在it_behaves_like块内分配变量,例如

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

第四,这也可以简化

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

最后,你会发现使用memoized let!会有很大帮助,因为你现在正在那些测试中创建大量的记录。这将大大降低性能,如果您使用具有某些全局唯一属性的模型,则测试将无处不在。

希望这有助于开始指向正确的方向

更新以控制测试操作

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

答案 1 :(得分:1)

这是改进的代码(基于engineermnky的建议)。欢迎任何进一步改进的建议。

控制器规格:

# 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

共享示例:

# 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

答案 2 :(得分:0)

对于let块,如果将模型作为参数传递给共享示例,就像使用redirect_path_helper一样,它是否不起作用?

include_examples "GET #index", Quote

然后在您的shared_example中,您可以使用record_name方法从FactoryGirl创建recordrecords并生成valid_attributes和invalid_attributes(您可以创建:invalid_quote工厂作为很好的无效属性,不确定那些是否被认为是一个很好的做法/想法与FactoryGirl)。

对于第二个问题,您不需要使用指定的路线助手,url_for(controller: :quote)url_for(@quote)都应该有效。