Skip to content

Commit

Permalink
Merge pull request #786 from rspec/any-args-splat
Browse files Browse the repository at this point in the history
Treat `any_args` as an arg splat.
  • Loading branch information
myronmarston committed Oct 5, 2014
2 parents c31e256 + 1cbb1c6 commit 4d87a45
Show file tree
Hide file tree
Showing 14 changed files with 270 additions and 77 deletions.
14 changes: 14 additions & 0 deletions Changelog.md
Expand Up @@ -15,6 +15,20 @@ Bug Fixes:
`have_received` matcher (they are not intended to be used together
and previously caused an odd internal failure in rspec-mocks). (Jon Rowe, #788).

Enhancements:

* Treat `any_args` as an arg splat, allowing it to match an arbitrary
number of args at any point in an arg list. (Myron Marston, #786)

Bug Fixes:

* Provide a clear error when users wrongly combine `no_args` with
additional arguments (e.g. `expect().to receive().with(no_args, 1)`).
(Myron Marston, #786)
* Provide a clear error when users wrongly use `any_args` multiple times in the
same argument list (e.g. `expect().to receive().with(any_args, 1, any_args)`.
(Myron Marston, #786)

### 3.1.1 / 2014-09-18
[Full Changelog](http://github.com/rspec/rspec-mocks/compare/v3.1.0...v3.1.1)

Expand Down
5 changes: 3 additions & 2 deletions README.md
Expand Up @@ -206,8 +206,9 @@ rspec-mocks also adds some keyword Symbols that you can use to
specify certain kinds of arguments:

```ruby
expect(double).to receive(:msg).with(no_args())
expect(double).to receive(:msg).with(any_args())
expect(double).to receive(:msg).with(no_args)
expect(double).to receive(:msg).with(any_args)
expect(double).to receive(:msg).with(1, any_args) # any args acts like an arg splat and can go anywhere
expect(double).to receive(:msg).with(1, kind_of(Numeric), "b") #2nd argument can be any kind of Numeric
expect(double).to receive(:msg).with(1, boolean(), "b") #2nd argument can be true or false
expect(double).to receive(:msg).with(1, /abc/, "b") #2nd argument can be any String matching the submitted Regexp
Expand Down
2 changes: 1 addition & 1 deletion features/basics/expecting_messages.feature
Expand Up @@ -19,7 +19,7 @@ Feature: Expecting messages
"""
1) An unfulfilled positive message expectation triggers a failure
Failure/Error: expect(dbl).to receive(:foo)
(Double "Some Collaborator").foo(any args)
(Double "Some Collaborator").foo(*(any args))
expected: 1 time with any arguments
received: 0 times with any arguments
"""
Expand Down
8 changes: 4 additions & 4 deletions features/basics/spies.feature
Expand Up @@ -70,15 +70,15 @@ Feature: Spies
"""
1) failure when the message has not been received for a spy
Failure/Error: expect(invitation).to have_received(:deliver)
(Double "invitation").deliver(any args)
(Double "invitation").deliver(*(any args))
expected: 1 time with any arguments
received: 0 times with any arguments
"""
And it should fail with:
"""
2) failure when the message has not been received for a partial double
Failure/Error: expect(Invitation).to have_received(:deliver)
(<Invitation (class)>).deliver(any args)
(<Invitation (class)>).deliver(*(any args))
expected: 1 time with any arguments
received: 0 times with any arguments
"""
Expand Down Expand Up @@ -119,7 +119,7 @@ Feature: Spies
| |
| 1) An invitiation fails when a count constraint is not satisfied |
| Failure/Error: expect(invitation).to have_received(:deliver).at_least(3).times |
| (Double "invitation").deliver(any args) |
| (Double "invitation").deliver(*(any args)) |
| expected: at least 3 times with any arguments |
| received: 2 times with any arguments |
| |
Expand All @@ -140,5 +140,5 @@ Feature: Spies
Then it should pass with:
"""
An invitation
should have received deliver(any args) 1 time
should have received deliver(*(any args)) 1 time
"""
2 changes: 1 addition & 1 deletion features/old_syntax/should_receive.feature
Expand Up @@ -36,7 +36,7 @@ Feature: `should_receive`
"""
1) An unfulfilled message expectation triggers a failure
Failure/Error: dbl.should_receive(:foo)
(Double "Some Collaborator").foo(any args)
(Double "Some Collaborator").foo(*(any args))
expected: 1 time with any arguments
received: 0 times with any arguments
"""
Expand Down
26 changes: 13 additions & 13 deletions features/outside_rspec/minitest.feature
Expand Up @@ -65,16 +65,16 @@ Feature: Integrate with Minitest
"""
When I run `ruby -Itest test/rspec_mocks_test.rb`
Then it should fail with the following output:
| 1) Error: |
| RSpecMocksTest#test_failing_negative_expectation: |
| RSpec::Mocks::MockExpectationError: (Double).message(no args) |
| expected: 0 times with any arguments |
| received: 1 time |
| |
| 2) Error: |
| RSpecMocksTest#test_failing_positive_expectation: |
| RSpec::Mocks::MockExpectationError: (Double).message(any args) |
| expected: 1 time with any arguments |
| received: 0 times with any arguments |
| |
| 4 runs, 0 assertions, 0 failures, 2 errors, 0 skips |
| 1) Error: |
| RSpecMocksTest#test_failing_negative_expectation: |
| RSpec::Mocks::MockExpectationError: (Double).message(no args) |
| expected: 0 times with any arguments |
| received: 1 time |
| |
| 2) Error: |
| RSpecMocksTest#test_failing_positive_expectation: |
| RSpec::Mocks::MockExpectationError: (Double).message(*(any args)) |
| expected: 1 time with any arguments |
| received: 0 times with any arguments |
| |
| 4 runs, 0 assertions, 0 failures, 2 errors, 0 skips |
8 changes: 4 additions & 4 deletions features/outside_rspec/standalone.feature
Expand Up @@ -27,7 +27,7 @@ Feature: Standalone
"""
When I run `ruby example.rb`
Then it should fail with the following output:
| (Double "greeter").say_hi(any args) |
| RSpec::Mocks::MockExpectationError |
| expected: 1 time with any arguments |
| received: 0 times with any arguments |
| (Double "greeter").say_hi(*(any args)) |
| RSpec::Mocks::MockExpectationError |
| expected: 1 time with any arguments |
| received: 0 times with any arguments |
1 change: 1 addition & 0 deletions features/setting_constraints/matching_arguments.feature
Expand Up @@ -9,6 +9,7 @@ Feature: Matching arguments
| Literal arguments | `with(1, true)` | `foo(1, true)` |
| Anything that supports case equality (`===`) | `with(/bar/)` | `foo("barn")` |
| Any list of args | `with(any_args)` | `foo()`<br>`foo(1)`<br>`foo(:bar, 2)` |
| Any sublist of args (like an arg splat) | `with(1, any_args)` | `foo(1)`<br>`foo(1, :bar, :bazz)` |
| An empty list of args | `with(no_args)` | `foo()` |
| Anything for a given positional arg | `with(3, anything)` | `foo(3, nil)`<br>`foo(3, :bar)` |
| Against an interface | `with(duck_type(:each))` | `foo([])` |
Expand Down
44 changes: 36 additions & 8 deletions lib/rspec/mocks/argument_list_matcher.rb
Expand Up @@ -44,12 +44,7 @@ class ArgumentListMatcher
# @see #args_match?
def initialize(*expected_args)
@expected_args = expected_args

@matchers = case expected_args.first
when ArgumentMatchers::AnyArgsMatcher then Array
when ArgumentMatchers::NoArgsMatcher then []
else expected_args
end
ensure_expected_args_valid!
end

# @api public
Expand All @@ -60,13 +55,46 @@ def initialize(*expected_args)
#
# @see #initialize
def args_match?(*args)
Support::FuzzyMatcher.values_match?(@matchers, args)
Support::FuzzyMatcher.values_match?(resolve_expected_args_based_on(args), args)
end

# @private
# Resolves abstract arg placeholders like `no_args` and `any_args` into
# a more concrete arg list based on the provided `actual_args`.
def resolve_expected_args_based_on(actual_args)
return [] if [ArgumentMatchers::NoArgsMatcher::INSTANCE] == expected_args

any_args_index = expected_args.index(ArgumentMatchers::AnyArgsMatcher::INSTANCE)
return expected_args unless any_args_index

replace_any_args_with_splat_of_anything(any_args_index, actual_args.count)
end

private

def replace_any_args_with_splat_of_anything(before_count, actual_args_count)
any_args_count = actual_args_count - expected_args.count + 1
after_count = expected_args.count - before_count - 1

any_args = 1.upto(any_args_count).map { ArgumentMatchers::AnyArgMatcher::INSTANCE }
expected_args.first(before_count) + any_args + expected_args.last(after_count)
end

def ensure_expected_args_valid!
if expected_args.count(ArgumentMatchers::AnyArgsMatcher::INSTANCE) > 1
raise ArgumentError, "`any_args` can only be passed to " \
"`with` once but you have passed it multiple times."
elsif expected_args.count > 1 && expected_args.include?(ArgumentMatchers::NoArgsMatcher::INSTANCE)
raise ArgumentError, "`no_args` can only be passed as a " \
"singleton argument to `with` (i.e. `with(no_args)`), " \
"but you have passed additional arguments."
end
end

# Value that will match all argument lists.
#
# @private
MATCH_ALL = new(ArgumentMatchers::AnyArgsMatcher.new)
MATCH_ALL = new(ArgumentMatchers::AnyArgsMatcher::INSTANCE)
end
end
end
47 changes: 34 additions & 13 deletions lib/rspec/mocks/argument_matchers.rb
Expand Up @@ -14,14 +14,18 @@ module Mocks
#
# @see ArgumentListMatcher
module ArgumentMatchers
# Matches any args at all. Supports a more explicit variation of
# `expect(object).to receive(:message)`
# Acts like an arg splat, matching any number of args at any point in an arg list.
#
# @example
#
# expect(object).to receive(:message).with(any_args)
# expect(object).to receive(:message).with(1, 2, any_args)
#
# # matches any of these:
# object.message(1, 2)
# object.message(1, 2, 3)
# object.message(1, 2, 3, 4)
def any_args
AnyArgsMatcher.new
AnyArgsMatcher::INSTANCE
end

# Matches any argument at all.
Expand All @@ -30,7 +34,7 @@ def any_args
#
# expect(object).to receive(:message).with(anything)
def anything
AnyArgMatcher.new
AnyArgMatcher::INSTANCE
end

# Matches no arguments.
Expand All @@ -39,7 +43,7 @@ def anything
#
# expect(object).to receive(:message).with(no_args)
def no_args
NoArgsMatcher.new
NoArgsMatcher::INSTANCE
end

# Matches if the actual argument responds to the specified messages.
Expand All @@ -58,7 +62,7 @@ def duck_type(*args)
#
# expect(object).to receive(:message).with(boolean())
def boolean
BooleanMatcher.new
BooleanMatcher::INSTANCE
end

# Matches a hash that includes the specified key(s) or key/value pairs.
Expand Down Expand Up @@ -122,19 +126,36 @@ def kind_of(klass)
# @private
def self.anythingize_lonely_keys(*args)
hash = args.last.class == Hash ? args.delete_at(-1) : {}
args.each { | arg | hash[arg] = AnyArgMatcher.new }
args.each { | arg | hash[arg] = AnyArgMatcher::INSTANCE }
hash
end

# Intended to be subclassed by stateless, immutable argument matchers.
# Provides a `<klass name>::INSTANCE` constant for accessing a global
# singleton instance of the matcher. There is no need to construct
# multiple instance since there is no state. It also facilities the
# special case logic we need for some of these matchers, by making it
# easy to do comparisons like: `[klass::INSTANCE] == args` rather than
# `args.count == 1 && klass === args.first`.
#
# @private
class SingletonMatcher
private_class_method :new

def self.inherited(subklass)
subklass.const_set(:INSTANCE, subklass.send(:new))
end
end

# @private
class AnyArgsMatcher
class AnyArgsMatcher < SingletonMatcher
def description
"any args"
"*(any args)"
end
end

# @private
class AnyArgMatcher
class AnyArgMatcher < SingletonMatcher
def ===(_other)
true
end
Expand All @@ -145,14 +166,14 @@ def description
end

# @private
class NoArgsMatcher
class NoArgsMatcher < SingletonMatcher
def description
"no args"
end
end

# @private
class BooleanMatcher
class BooleanMatcher < SingletonMatcher
def ===(value)
true == value || false == value
end
Expand Down
26 changes: 9 additions & 17 deletions lib/rspec/mocks/verifying_message_expecation.rb
Expand Up @@ -23,31 +23,23 @@ def initialize(*args)

# @private
def with(*args, &block)
unless ArgumentMatchers::AnyArgsMatcher === args.first
expected_args = if ArgumentMatchers::NoArgsMatcher === args.first
[]
elsif args.length > 0
args
else
# No arguments given, this will raise.
super
end

validate_expected_arguments!(expected_args)
super(*args, &block).tap do
validate_expected_arguments! do |signature|
example_call_site_args = [:an_arg] * signature.min_non_kw_args
example_call_site_args << :kw_args_hash if signature.required_kw_args.any?
@argument_list_matcher.resolve_expected_args_based_on(example_call_site_args)
end
end
super
end

private

def validate_expected_arguments!(actual_args)
def validate_expected_arguments!
return if method_reference.nil?

method_reference.with_signature do |signature|
verifier = Support::LooseSignatureVerifier.new(
signature,
actual_args
)
args = yield signature
verifier = Support::LooseSignatureVerifier.new(signature, args)

unless verifier.valid?
# Fail fast is required, otherwise the message expecation will fail
Expand Down

0 comments on commit 4d87a45

Please sign in to comment.