rubyrspecrspec3

Is there a way in RSpec to assert both number of calls and the list of arguments together?


I want to assert that a certain method is called exactly N times (no more, no less) with specific arguments with a specific order. Also I don't want to actually execute this method so first I stub it with allow().

Suppose I have this code:

class Foo
  def self.hello_three_times
    Foo.hello(1)
    Foo.hello(2)
    Foo.hello(3)
  end

  def self.hello(arg)
    puts "hello #{arg}"
  end
end

I want to test method hello_three_times that it actually calls hello three times with 1, 2, and 3 as arguments. (And I don't want to really call hello in tests because in reality it contains side effects and is slow.)

So, if I write this test

RSpec.describe Foo do
  subject { Foo.hello_three_times }

  it do
    allow(Foo).to receive(:hello).and_return(true)
    expect(Foo).to receive(:hello).with(1).once.ordered
    expect(Foo).to receive(:hello).with(2).once.ordered
    expect(Foo).to receive(:hello).with(3).once.ordered
    subject
  end
end

it passes but it doesn't guarantee there are no additional calls afterwards. For example, if there is a bug and method hello_three_times actually looks like this

def self.hello_three_times
  Foo.hello(1)
  Foo.hello(2)
  Foo.hello(3)
  Foo.hello(4)
end

the test would still be green.

If I try to combine it with exactly(3).times like this

RSpec.describe Foo do
  subject { Foo.hello_three_times }

  it do
    allow(Foo).to receive(:hello).and_return(true)
    expect(Foo).to receive(:hello).exactly(3).times
    expect(Foo).to receive(:hello).with(1).once.ordered
    expect(Foo).to receive(:hello).with(2).once.ordered
    expect(Foo).to receive(:hello).with(3).once.ordered
    subject
  end
end

it fails because RSpec seems to be treating the calls as fulfilled after the first expect (probably in this case it works in such a way that it expects to have 3 calls first, and then 3 more calls individually, so 6 calls in total):

Failures:
  1) Foo is expected to receive hello(3) 1 time
     Failure/Error: expect(Foo).to receive(:hello).with(1).once.ordered
       (Foo (class)).hello(1)
           expected: 1 time with arguments: (1)
           received: 0 times

Is there a way to combine such expectations so that it guarantees there are exactly 3 calls (no more, no less) with arguments being 1, 2, and 3 (ordered)?


Solution

  • Oh, I think I found the solution. I can use a block for that:

    RSpec.describe Foo do
      subject { Foo.hello_three_times }
    
      let(:expected_arguments) do
        [1, 2, 3]
      end
    
      it do
        allow(Foo).to receive(:hello).and_return(true)
        call_index = 0
        expect(Foo).to receive(:hello).exactly(3).times do |argument|
          expect(argument).to eq expected_arguments[call_index]
          call_index += 1
        end
        subject
      end
    end
    

    It gets the job done guaranteeing there are exactly 3 calls with correct arguments.

    It doesn't look very pretty though (introducing that local variable call_index, ugh). Maybe there are prettier solutions out of the box?