Igor P
Igor P

Reputation: 629

How to isolate a test from external service dependency in RSpec

Setup

In one of the controller actions we have an external service dependency like this:

def delete
  @user = User.find(params[:id])

  # Fail if the user is registered on SomeService
  external = SomeService::Client.new(app_id: SOME_ID, api_key: SOME_KEY)
  if external.users.find(user_id: @user.id)
    fail Error::SomeService::ResourcePresent
  end

  @user.destroy!

  render nothing: true, status: :ok
end

And a very simple spec that tests the action:

context "#delete" do
  it "should return the correct success status" do
    user = User.create!(email: "[email protected]", password: "12345678")

    delete :delete, id: user.id
    expect(response).to have_http_status(:ok)
  end
end

The Problem

I would like to isolate the controller action from its dependency on SomeService and only test the actual deletion of the user.

What's a good approach for bypassing the external so my tests can pass?

Upvotes: 2

Views: 993

Answers (2)

Jon
Jon

Reputation: 10898

You could use something like this:

context "#delete" do
  before(:each) do 
    # First of all, you'll want to mock out the service and return your mocked service instance
    service_double = double # This is the mocked object you want to control
    some_service_client = class_double("SomeService::Client") # This mocks the class that you're calling
    expect(some_service_client).to receive(:new).and_return(service_double) # This stubs the class method and returns your mocked object
  end

  it "should return the correct success status" do
    # Create your user as usual
    user = User.create!(email: "[email protected]", password: "12345678")

    # We'll just return the same mocked object from the chained method
    expect(service_double).to receive(:users).and return(service_double)

    # And we'll test the correct user was passed to the service, and control the response
    expect(service_double).to receive(:find).with({userid: user.id}).and return(false)

    delete :delete, id: user.id
    expect(response).to have_http_status(:ok)
  end
end

Stubbing Chained Methods

RSpec has a handy solution for this, which you could use here. It goes something like this:

service_double.stub_chain(:users, :find).and_return(true)

However, this is only really useful if you don't care how they were called. In the example solution at the top of this answer we test that the correct user was passed into the chain, and hence we return our double from the first chained method so that we can set expectations on the second part of the chain.

Upvotes: 2

Igor P
Igor P

Reputation: 629

Possible solution

Extract the dependency into a separate method and stub it like this:

Controller:

def delete
  @user = User.find(params[:id])
  fail_if_present_on_some_service(@user)
  @user.destroy!
  render nothing: true, status: :ok
end

def fail_if_present_on_some_service(user)
  external = SomeService::Client.new(app_id: SOME_ID, api_key: SOME_KEY)

  if external.users.find(user_id: @user.id)
    fail Error::SomeService::ResourcePresent
  end
end

And stubbing this method inside a before block like this:

context "#delete" do
  before :context do
    allow(controller).to receive(:fail_if_present_on_some_service) { true }
  end

  it "should return the correct status" do
    user = User.create!(email: "[email protected]", password: "12345678")

    delete :delete, id: user.id
    expect(response).to have_http_status(:ok)
  end
end

Is this a good approach?

Upvotes: 1

Related Questions