'Using RSpec to test a Retry RestClient

I'm using Oauth so what I do is store access_token and refresh token at User table, I create some classes to do this. In the Create class I do the normal functionality of the code (create records on the integration). The access_token expire at 1 hour, so intead of schedule an active job to refresh that token at that time I decided to do Refresh.new(user).call to request a new access_token and refresh_token.

I know that code works, because I've tested on live and I'm getting the new token when the access_token is expired. But I want to do a rspec test for this.

part of my rspec test:.

context 'when token is expired' do
  it 'request a refresh token and retry' do
    old_key = user.access_token
    allow(RestClient)
      .to receive(:post)
      .and_raise(RestClient::Unauthorized).once
    expect { Create.new.call }.to change { user.reload.access_token }.from(old_key)
  end
end

This is the response:

(RestClient).post(# data)
           expected: 1 time with any arguments
           received: 2 times with arguments: (# data)

This is my code:

Create.rb

class Create
  def initialize(user)
    @user = user
    @refresh_token = user&.refresh_token
    @access_token = user&.access_token
    @logger = Rails.logger
    @message = Crm::Message.new(self.class, 'User', user&.id)
  end

  def call
    # validations
    create_contact
  rescue RestClient::Unauthorized => ex
    retry if Refresh.new(user).call
  rescue RestClient::ExceptionWithResponse => ex
    logger.error(@message.api_error(ex))
    raise
  end

  private

  attr_reader :user, :logger, :access_token, :refresh_token

  def create_contact
    response = RestClient.post(
      url, contact_params, contact_headers
    )
    logger.info(@message.api_response(response))
  end
end

Refresh.rb

class Refresh
  def initialize(user)
    @user = user
    @refresh_token = user&.refresh_token
    @access_token = user&.access_token
    @logger = Rails.logger
    @message = Crm::Message.new(self.class, 'User', user&.id)
  end

  def call
    # validations
    refresh_authorization_code
  end

  def refresh_authorization_code
    response = RestClient.post(url, authorization_params)
    logger.info(@message.api_response(response))
    handle_response(response)
  end

  private

  attr_reader :user, :logger, :access_token, :refresh_token

  def handle_response(response)
    parsed = JSON.parse(response)
    user.update!(access_token: parsed[:access_token], refresh_token: parsed[:refresh_token])
  end
end

Also I tried using something like this from here

 errors_to_raise = 2
allow(RestClient).to receive(:get) do
  return rest_response if errors_to_raise <= 0
  errors_to_raise -= 1
  raise RestClient::Unauthorized
end
# ...
expect(client_response.code).to eq(200)

but I don't know how handle it propertly.



Solution 1:[1]

Your test calls RestClient.post twice, first in Create then again in Retry. But you only mocked one call. You need to mock both calls. The first call raises an exception, the second responds with a successful result.

We could do this by specifying an order with ordered...

context 'when token is expired' do
  it 'request a refresh token and retry' do
    old_key = user.access_token
    
    # First call fails
    allow(RestClient)
      .to receive(:post)
      .and_raise(RestClient::Unauthorized)
      .ordered
    
    # Second call succeeds and returns an auth response.
    # You need to write up that auth_response.
    # Alternatively you can .and_call_original but you probably
    # don't want your tests making actual API calls.
    allow(RestClient)
      .to receive(:post)
      .and_return(auth_response)
      .ordered

    expect { Create.new.call }.to change { user.reload.access_token }.from(old_key)
  end
end

However, this makes a lot of assumptions about exactly how the code works, and that nothing else calls RestClient.post.

More robust would be to use with to specify responses with specific arguments, and also verify the correct arguments are being passed.

context 'when token is expired' do
  it 'request a refresh token and retry' do
    old_key = user.access_token
    
    # First call fails
    allow(RestClient)
      .to receive(:post)
      .with(...whatever the arguments are...)
      .and_raise(RestClient::Unauthorized)
    
    # Second call succeeds and returns an auth response.
    # You need to write up that auth_response.
    # Alternatively you can .and_call_original but you probably
    # don't want your tests making actual API calls.
    allow(RestClient)
      .to receive(:post)
      .with(...whatever the arguments are...)
      .and_return(auth_response)

    expect { Create.new.call }.to change { user.reload.access_token }.from(old_key)
  end
end

But this still makes a lot of assumptions about exactly how the code works, and you need to make a proper response.

Better would be to focus in on exactly what you're testing: when the create call gets an unauthorized exception it tries to refresh and does the call again. This unit test doesn't have to also test that Refresh#call works, just that Create#call calls it. You don't need to have RestClient.post raise an exception, just that Create#create_contact does.

context 'when token is expired' do  
  it 'requests a refresh token and retry' do
    old_key = user.access_token
    create = Create.new(user)
    
    # First call fails
    allow(create)
      .to receive(:create_contact)
      .and_raise(RestClient::Unauthorized)
      .ordered

    # It refreshes
    refresh = double
    expect(Refresh)
      .to receive(:new)
      .with(user)
      .and_return(refresh)

    # The refresh succeeds
    expect(refresh)
      .to receive(:call)
      .with(no_args)
      .and_return(true)

    # It tries again
    expect(create)
      .to receive(:create_contact)
      .ordered
    
    create.call
  end
end

And you can also test when the retry fails. These can be combined together.

context 'when token is expired' do
  let(:refresh) { double }
  let(:create) { Create.new(user) }
  
  before {
    # First call fails
    allow(create)
      .to receive(:create_contact)
      .and_raise(RestClient::Unauthorized)
      .ordered
    
    # It tries to refresh
    expect(Refresh)
      .to receive(:new)
      .with(user)
      .and_return(refresh)
  }
  
  context 'when the refresh succeeds' do
    before {
      # The refresh succeeds
      allow(refresh)
        .to receive(:call)
        .with(no_args)
        .and_return(true)
    }
  
    it 'retries' do
      expect(create)
        .to receive(:create_contact)
        .ordered
      
      create.call
    end
  end
  
  context 'when the refresh fails' do
    before {
      # The refresh succeeds
      allow(refresh)
        .to receive(:call)
        .with(no_args)
        .and_return(false)
    }
  
    it 'does not retry' do
      expect(create)
        .not_to receive(:create_contact)
        .ordered
      
      create.call
    end
  end
end

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Schwern