rubyrspecrspec-expectations

Ruby rspec expectations: Is there any way to ensure a keyword expectation is not "bogus"?


Let's say I have:

class Foo
  def do_thing(arg_one:, arg_two:)
    # ...
  end
end

class Bar
  def initialize(foo)
    @foo = foo
  end

  def do_delegated_thing
    @foo.do_thing(arg_one: "x", arg_two: "y")
  end
end

and a test:

describe Bar do
  let(:mock_foo) do
    foo = double(Foo)
    allow(foo).to receive(:do_thing)
    foo
  end

  let(:subject) { described_class.new(mock_foo) }

  it "calls Foo with expected arguments" do
    subject.do_delegated_thing

    expect(mock_foo).to have_received(:do_thing).with(arg_one: "x", arg_two: "y")
  end
end

Now let's say I want to refactor Foo and change an argument name:

class Foo
  def do_thing(arg_one_hundred:, arg_two:)
    # ...
  end
end

The test will still pass, even though the expectation receive(:do_thing).with(arg_one: "x", arg_two: "y") is now invalid. In my mind, do_thing should not be able to be expected to be called with invalid arguments. Is there a sane way around this, i.e. a better RSpec API method I should be using to ensure that the expectation is actually legitimate?


Solution

  • What you are looking for is a Verifying Double in this case an instance_double.

    From the Docs (emphasis added):

    An instance_double is the most common type of verifying double. It takes a class name or object as its first argument, then verifies that any methods being stubbed would be present on an instance of that class. In addition,when it receives messages, it verifies that the provided arguments are supported by the method signature, both in terms of arity and allowed or required keyword arguments, if any.

    For Example, if you change foo = double(Foo) to foo = instance_double(Foo) your original test will pass and when you change the method to def do_thing(arg_one_hundred:, arg_two:) it will fail.

    Working Example:

    class Foo
      def do_thing(arg_one:, arg_two:); end
    end
    
    class Baz
      def do_thing(arg_one_hundred:, arg_two:); end
    end 
    
    class Bar
      def initialize
        @foo = Foo.new
      end
    
      def do_delegated_thing
        foo.do_thing(arg_one: "x", arg_two: "y")
      end
      private 
         attr_reader :foo
    end
    
    describe Bar do
      let(:mock_foo) {instance_double(Foo, do_thing: nil)} 
      let(:mock_baz) {double(Baz, do_thing: nil)}
      let(:mock_baz_2) {instance_double(Baz, do_thing: nil)}
    
      let(:subject) { described_class.new }
    
      it "calls Foo with expected arguments" do
        allow(subject).to receive(:foo).and_return(mock_foo)
        expect(mock_foo).to receive(:do_thing).with(arg_one: "x", arg_two: "y")
        subject.do_delegated_thing
      end
      context 'comparing double and instance_double' do 
        it "calls double with unexpected arguments" do
          allow(subject).to receive(:foo).and_return(mock_baz)
          expect(mock_baz).to receive(:do_thing).with(arg_one: "x", arg_two: "y")
          subject.do_delegated_thing
        end
        it "calls instance_double with unexpected arguments" do
          allow(subject).to receive(:foo).and_return(mock_baz_2)
          expect(mock_baz_2).to receive(:do_thing).with(arg_one: "x", arg_two: "y")
          subject.do_delegated_thing
        end
      end
    end
    

    Output:

    Bar
      calls Foo with expected arguments
      comparing double and instance_double
        calls double with unexpected arguments
        calls instance_double with unexpected arguments (FAILED - 1)
    
    Failures:
    
      1) Bar comparing double and instance_double calls instance_double with unexpected arguments
         Failure/Error: expect(mock_baz_2).to receive(:do_thing).with(arg_one: "x", arg_two: "y")
           Missing required keyword arguments: arg_one_hundred
         # ./spec.rb:42:in `block (3 levels) in <top (required)>'
         # main.rb:22:in `<main>'
    
    Finished in 0.00752 seconds (files took 0.0997 seconds to load)
    3 examples, 1 failure