Skip to content

Commit

Permalink
Implement optional strict keyword arg matching
Browse files Browse the repository at this point in the history
When the strict keyword argument option is enabled, an expectation
expecting keyword arguments (via Expectation#with) will no longer match
an invocation passing a positional Hash argument.

Without this option, positional hash and keyword arguments are treated
the same during comparison, which can lead to false negatives in
Ruby >= v3.0 (see examples below).

For more details on keyword arguments in Ruby v3, refer to this
article [1].

Note that Hash matchers such as has_value or has_key will still treat
positional hash and keyword arguments the same, so false negatives are
still possible when they are used.

Closes #446.

See also #535 & #544 for discussions relating to this change.

[1]: https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0
  • Loading branch information
wasabigeek authored and floehopper committed Oct 9, 2022
1 parent 1b4b6bb commit 98487db
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 4 deletions.
8 changes: 4 additions & 4 deletions lib/mocha/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -306,16 +306,16 @@ def reinstate_undocumented_behaviour_from_v1_9?
@options[:reinstate_undocumented_behaviour_from_v1_9]
end

# Configure whether to perform strict keyword argument comparision. Only supported in Ruby >= 2.7.
# Configure whether to perform strict keyword argument comparision. Only supported in Ruby >= v2.7.
#
# Without this option, positional hash and keyword arguments are treated the same during comparison, which can lead to false
# negatives in Ruby >= 3.0 (see examples below). For more details on keyword arguments in Ruby 3, refer to the relevant
# {https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0 blog post}.
# negatives in Ruby >= v3.0 (see examples below). For more details on keyword arguments in Ruby v3, refer to
# {https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0 this article}.
#
# Note that Hash matchers such as +has_value+ or +has_key+ will still treat positional hash and keyword arguments the same,
# so false negatives are still possible when they are used.
#
# This is turned off by default to enable gradual adoption, and may be turned on by default in the future.
# This configuration option is turned off by default to enable gradual adoption, but may be turned on by default in the future.
#
# When +value+ is +true+, strict keyword argument matching will be enabled.
# When +value+ is +false+, strict keyword argument matching is disabled. This is the default.
Expand Down
9 changes: 9 additions & 0 deletions lib/mocha/parameter_matchers/instance_methods.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'mocha/parameter_matchers/equals'
require 'mocha/parameter_matchers/positional_or_keyword_hash'

module Mocha
module ParameterMatchers
Expand All @@ -16,3 +17,11 @@ def to_matcher
class Object
include Mocha::ParameterMatchers::InstanceMethods
end

# @private
class Hash
# @private
def to_matcher
Mocha::ParameterMatchers::PositionalOrKeywordHash.new(self)
end
end
31 changes: 31 additions & 0 deletions lib/mocha/parameter_matchers/positional_or_keyword_hash.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
require 'mocha/configuration'
require 'mocha/parameter_matchers/base'

module Mocha
module ParameterMatchers
# @private
class PositionalOrKeywordHash < Base
def initialize(value)
@value = value
end

def matches?(available_parameters)
parameter, is_last_parameter = extract_parameter(available_parameters)
if is_last_parameter && Mocha.configuration.strict_keyword_argument_matching?
return false unless ::Hash.ruby2_keywords_hash?(parameter) == ::Hash.ruby2_keywords_hash?(@value)
end
parameter == @value
end

def mocha_inspect
@value.mocha_inspect
end

private

def extract_parameter(available_parameters)
[available_parameters.shift, available_parameters.empty?]
end
end
end
end
52 changes: 52 additions & 0 deletions test/acceptance/keyword_argument_matching_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ def test_should_match_hash_parameter_with_keyword_args
assert_passed(test_result)
end

if Mocha::RUBY_V27_PLUS
def test_should_not_match_hash_parameter_with_keyword_args_when_strict_keyword_matching_is_enabled
test_result = run_as_test do
mock = mock()
mock.expects(:method).with(:key => 42)
Mocha::Configuration.override(:strict_keyword_argument_matching => true) do
mock.method({ :key => 42 }) # rubocop:disable Style/BracesAroundHashParameters
end
end
assert_failed(test_result)
end
end

def test_should_match_hash_parameter_with_splatted_keyword_args
test_result = run_as_test do
mock = mock()
Expand All @@ -29,6 +42,19 @@ def test_should_match_hash_parameter_with_splatted_keyword_args
assert_passed(test_result)
end

if Mocha::RUBY_V27_PLUS
def test_not_should_match_hash_parameter_with_splatted_keyword_args_when_strict_keyword_matching_is_enabled
test_result = run_as_test do
mock = mock()
mock.expects(:method).with(**{ :key => 42 })
Mocha::Configuration.override(:strict_keyword_argument_matching => true) do
mock.method({ :key => 42 }) # rubocop:disable Style/BracesAroundHashParameters
end
end
assert_failed(test_result)
end
end

def test_should_match_splatted_hash_parameter_with_keyword_args
test_result = run_as_test do
mock = mock()
Expand Down Expand Up @@ -56,6 +82,19 @@ def test_should_match_positional_and_keyword_args_with_last_positional_hash
assert_passed(test_result)
end

if Mocha::RUBY_V27_PLUS
def test_should_not_match_positional_and_keyword_args_with_last_positional_hash_when_strict_keyword_args_is_enabled
test_result = run_as_test do
mock = mock()
mock.expects(:method).with(1, { :key => 42 }) # rubocop:disable Style/BracesAroundHashParameters
Mocha::Configuration.override(:strict_keyword_argument_matching => true) do
mock.method(1, :key => 42)
end
end
assert_failed(test_result)
end
end

def test_should_match_last_positional_hash_with_keyword_args
test_result = run_as_test do
mock = mock()
Expand All @@ -65,6 +104,19 @@ def test_should_match_last_positional_hash_with_keyword_args
assert_passed(test_result)
end

if Mocha::RUBY_V27_PLUS
def test_should_not_match_last_positional_hash_with_keyword_args_when_strict_keyword_args_is_enabled
test_result = run_as_test do
mock = mock()
mock.expects(:method).with(1, :key => 42)
Mocha::Configuration.override(:strict_keyword_argument_matching => true) do
mock.method(1, { :key => 42 }) # rubocop:disable Style/BracesAroundHashParameters
end
end
assert_failed(test_result)
end
end

def test_should_match_positional_and_keyword_args_with_keyword_args
test_result = run_as_test do
mock = mock()
Expand Down

0 comments on commit 98487db

Please sign in to comment.