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