如何在 RSpec 中正确模拟内部服务?

How to properly mock itnernal services in RSpec?

我想学习如何在其他 classes 中正确模拟对象调用,例如我有这个控制器操作:

def show
 service = Action::PartsShow.new(show_params, current_user)
 service.call
 render json: service.part, root: :part, serializer: PartSerializer, include: '**',
        scope: {current_user: current_user}
end

服务 class 看起来像这样。

module Action
  class PartsShow < PartsShowBase
    def find_part
      ...
    end
  end
end

module Action
  class PartsShowBase
    attr_reader :part

    def initialize(params, current_user)
      @params = params
      @current_user = current_user
    end

    def call
      find_part
      reload_part_availability
      reload_part_price if @current_user.present?
    end

    private

    def reload_part_availability
      ReloadPartAvailabilityWorker.perform_async(part.id)
    end

    def reload_part_price
      ExternalData::LauberApi::UpdatePrices.new(@current_user, [part]).execute
    end
  end
end

我不想在这个控制器操作和所有其他方法、服务 + 工作人员中调用实际的 Action::PartsShow 服务,因为这会使测试非常慢。我想要的是测试是否正在调用此服务并模拟其余服务。我不想在我的测试中调用它们,我想模拟它们。

我的测试是这样的:

RSpec.describe PartController, type: :request do
  describe 'GET #show' do
    let(:part) { create(:part) }

    subject { get "/api/v1/parts/#{part.id}" }

    expect(response_body).to eq(200)
    # ...
  end
end

你能告诉我如何正确模拟它吗?我读过 RSpec 模拟和存根,但我对此感到困惑。非常感谢您的帮助。

有了rspec-mocks gem,就可以使用allow_any_instance_of。通常,这部分位于 before 块中。

其实Action::PartsShow负责加载一部分,所以不需要泄漏callpart两个实例方法。您可以通过从 call.

返回部分来简化它
module Action
  class PartsShowBase
    #attr_reader :part

    def call
      find_part # assign @part
      reload_part_availability
      reload_part_price if @current_user.present?
      @part
    end
    ...
end
RSpec.describe PartController, type: :request do
  before :all do
    allow_any_instance_of(Action::PartsShow).to receive(:call).and_return(returned_part)
  end

参考

https://relishapp.com/rspec/rspec-mocks/v/3-5/docs/working-with-legacy-code/any-instance

假设find_part调用Part.find(id),你可以添加:

before do
  allow(Part).to receive(:find).with(part.id).and_return(part)
end

确保记录查找总是returns你的测试对象。我还建议稍微修改一下您的规范:

RSpec.describe PartController, type: :request do
  subject { response }
  
  describe '#show' do
    let(:request)  { get api_v1_part_path(part.id) }
    # If you insist on mocking the object, might as well use build_stubbed
    let(:part)     { build_stubbed(:part) } 
    let(:json)     { JSON.parse(response.body).deep_symbolize_keys }
    let(:expected) {
      {
        part: {
          id: parts.id,
          # ...
        }
      }
    }

    before do
      # This is the recommended way to mock an object
      allow(Part).to receive(:find).with(part.id).and_return(part)
      request
    end

    # Validate response status
    # https://relishapp.com/rspec/rspec-rails/docs/matchers/have-http-status-matcher
    # If you are using shoulda matchers - it works bc subject is the response
    it { should have_http_status(:success) }
    # otherwise
    it { expect(response).to have_http_status(:success) }

    # Validate response body
    it { expect(json).to eq(expected) }
  end
end

如果您的项目有 path helpers,我也建议使用这些而不是路径字符串。