Louis Pate
Louis Pate

Reputation: 11

Can I write one expectation in RSpec which demands a method is called only once with specific arguments?

I'm just finishing my first year writing production code in Rails, and I'm not really stuck here, but the non-rubyness of what I'm experiencing is weird, and I wanted to see if anyone had this problem.

Say you're writing code which involves an external API, so the calls you make to that API need to be carefully crafted. In order to make sure these calls are happening correctly, I want to write some specs which mandate that these calls only happen a certain a number of times and with certain arguments. Something like this:

it "only calls the wrapper once with the correct arguments" do
  expect(Wrapper).to have_received(:do_magic).once
  expect(Wrapper).to have_received(:do_magic).with(flowers_in_sleeve: false)
end

But having two expectations is bad practice! I use RuboCop to lint (and I like sticking to it's conventions) so there are two options for me: either break this into two expectations or disable the rule for having multiple expectations in a block.

So, this isn't a totally earth shattering problem. But it seems very strange to me that RSpec doesn't have a more convenient way to satisfy this case. Maybe I just need to suck it up and write the two expectations. My goal is to do this in one expectation if possible.

This was the main solution I thought of:

it "only calls the wrapper once with the correct arguments" do
  expect(Wrapper).to have_received(:do_magic).once.with(flowers_in_sleeve: false)
end

But that allows many calls to the wrapper, so long as you call the method at least once with those arguments. Which, really, if you look at the sentence you wrote:

I expect wrapper to be called once with flowers in sleeve to be false

This behavior makes sense, because I'm not saying:

I expect wrapper to be called only once with flower in sleeve to be false.

Upvotes: 1

Views: 200

Answers (2)

Maxim Krizhanovsky
Maxim Krizhanovsky

Reputation: 26729

RuboCop wise you have couple of options. Obvious one is to set the Max for MultipleExpectation to 2. But that would apply to all tests.

A better one is to wrap the two expectations in an aggregate_failures block.

As for the RSpec syntax, it's hard to specify whether you want this particular set of arguments to appear once, or whether it should have only one call and it should match the arguments.

That being said, you can do

expect(Wrapper).to receive(:do_magic).once do |flowers_in_sleeve:|
  expect(flowers_in_sleeve).to eq(false)
end

which as far as I remember will also trigger the MultipleExpecations offence.

Upvotes: 0

Mikhail Golubitsky
Mikhail Golubitsky

Reputation: 698

As @max commented, having multiple expectations isn't necessarily bad practice. It's entirely possible that multiple expect statements can be helpful to cover one behavior. You are free to modify the RuboCop defaults when you have a compelling reason to do so.

That being said, here's a pattern you can use to write specs that mandate that calls only happen a certain a number of times and with certain arguments. You can capture all the arguments passed to each call of do_magic and then expect against the set of arguments.

# lib/magic_performer.rb
class Wrapper
  def self.do_magic(options = {})
    # implementation goes here
  end
end

class MagicPerformer
  def perform_magic
    Wrapper.do_magic(flowers_in_sleeve: false)
  end
end
# spec/magic_performer_spec.rb
require 'rspec'
require_relative '../lib/magic_performer'

RSpec.describe MagicPerformer do
  let(:args_in_calls_to_do_magic) { [] }

  before do
    allow(Wrapper).to receive(:do_magic) do |*args, **kwargs|
      args_in_calls_to_do_magic << [args, kwargs]
    end

    MagicPerformer.new.perform_magic
  end

  it 'only calls the wrapper once with the correct arguments' do
    expect(args_in_calls_to_do_magic)
      .to eq([
               [[], { flowers_in_sleeve: false }]
             ])
  end
end

Separately, consider that this type of test is a "white-box" test (as opposed to a "black-box" test). Such a test is aware of the implementation details of MagicPerformer, the class under test. Generally, it is better to write a test that is not aware of any such implementation details as MagicPerformer internally calling Wrapper. Black box tests enable you, in the future, to refactor the class under test—for example to remove its dependency on Wrapper—without changing the tests. Consider whether it's possible to rewrite the test to expect only with respect to the observable behavior of Wrapper; that is, to expect against either the return value of MagicPerformer or public state that it modifies when called.

Upvotes: 0

Related Questions