Unexpected arguments message from a mock in rspec created with an implicit hash (2.7 -> 3.x)


The Problem

A few days ago, a test started breaking with an unexpected arguments message but what was weird was the comparison:

   #<InstanceDouble(Chef::Environment) (anonymous)> received :cookbook_versions with unexpected arguments
     expected: ({"apt"=>"= 1.0.0", "jenkins"=>"= 1.4.5"})
          got: ({"apt"=>"= 1.0.0", "jenkins"=>"= 1.4.5"})

It… expected a Hash and got a hash??

Looking at the mock setup, however, I noticed that it was set up as an implicit hash: .with("apt" => "= 1.0.0", "jenkins" => "= 1.4.5")

Setting up in isolation

This is ultimately the chain of mocks and invoked methods being mocked. Note that the mocked method receives a single argument which defaults to nil

require "rspec"
require "rspec/its"

class Why
  def versions(arg = nil)
  end
end

describe "Hash Fail" do
  it "doesn't like implicit hash" do
    why = instance_double(Why)
    env_hash = double(Hash)

    expect(Why).to receive(:from_hash).with(env_hash).and_return(why)
    expect(why).to receive(:versions).with("apt" => "= 1.0.0", "jenkins" => "= 1.4.5")

    env = Why.from_hash(env_hash)
    why.versions( { "apt" => "= 1.0.0", "jenkins" => "= 1.4.5" } )
  end
end

In Ruby 2.7, this passes

.

Finished in 0.01598 seconds (files took 0.13614 seconds to load)
1 example, 0 failures 

However, in Ruby 3.0:

F

Failures:

  1) Hash Fail doesn't like implicit hash
     Failure/Error: why.versions( { "apt" => "= 1.0.0", "jenkins" => "= 1.4.5" } )

       #<InstanceDouble(Why) (anonymous)> received :versions with unexpected arguments
         expected: ({"apt"=>"= 1.0.0", "jenkins"=>"= 1.4.5"}) (keyword arguments)
              got: ({"apt"=>"= 1.0.0", "jenkins"=>"= 1.4.5"}) (options hash)
       Diff:

     # ./spec/fail_hash_spec.rb:21:in `block (2 levels) in <top (required)>'

Finished in 0.0248 seconds (files took 0.14036 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/fail_hash_spec.rb:13 # Hash Fail doesn't like implicit hash 

The problem is that now the arguments are treated as keyword arguments (obvious from the message in the isolated test). I believe this is a result of the Separation of Keyword and Positional Arguments in 3.0. Before 3.0, the implicit hash would be coerced into a hash to fit the single argument. After 3.0, it’s assumed to be keyword arguments. Adding braces around the implicit hash makes it pass:

require "rspec"
require "rspec/its"

class Why
  def versions(arg = nil)
  end
end

describe "Hash Fail" do
  it "doesn't like implicit hash" do
    why = instance_double(Why)
    env_hash = double(Hash)

    expect(Why).to receive(:from_hash).with(env_hash).and_return(why)
    expect(why).to receive(:versions).with({"apt" => "= 1.0.0", "jenkins" => "= 1.4.5"})

    env = Why.from_hash(env_hash)
    why.versions( { "apt" => "= 1.0.0", "jenkins" => "= 1.4.5" } )
  end
end

Now 3.0 passes:

.

Finished in 0.01288 seconds (files took 0.14016 seconds to load)
1 example, 0 failures


Leave a Reply

%d bloggers like this: