Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Add test spies #241

Merged
merged 18 commits into from
@jferris
  • Adds have_received matcher
  • Extends Proxy/MethodDouble/MessageExpectation
  • Records all invocations on stubbed methods
  • Resolves #220.

I ended up including a have_received method in rspec-mocks and not touching rspec-expectations, because:

  • have_received returns a good result object (a matcher) that you can use for boolean checks and a failure message, which we want in rspec-mocks
  • have_received would make little sense in rspec-expectations if you weren't also using rspec-mocks

I resurrected some invocation recording functionality in received_messages? that seems to have been somewhat broken for a while. It was added in 2007 for ZenTest support, and seems to have been moved forward with every release without modification since then. Previously it only ever recorded messages for null object mocks; I updated it to record messages for any stubbed method.

The matcher works in negative cases using should_not/not_to; I decided not to implement never, because combining should_not and never has proven to be confusing to bourne users. I figured it would be easier to just have one, standard way to write negative expectations.

There was one weird situation I wasn't sure how to deal with on MethodDouble. Every MethodExpectation expects to have a expected_from backtrace line, because previously errors would be inserted where the expectation was set. This isn't necessary with have_received, because it raises a normal exception from where the expectation failed. I ended up passing a useless backtrace line with caller(1)[0] that isn't ever used.

Feedback welcome.

Joe Ferris and Joël Quenneville Add test spies
* Adds have_received matcher
* Extends Proxy/MethodDouble/MessageExpectation
* Records all invocations on stubbed methods
e8cae54
@jferris jferris referenced this pull request
Closed

Add Syntax for Spies #220

@adarsh

+1 this would be great.

@MDaubs

:+1: Having used rspec-spies and bourne it will be nice to have spy syntax included. This implementation is more cleanly implemented than rspec-spies as well. Looks like there are a few 1.9 hashes breaking the 1.8 tests, though. Thanks @jferris

@myronmarston

@jferris -- thanks for tackling this so quickly and so thoroughly! I'm going to look at it in more detail (and make comments) now.

features/spies/spy_unstubbed_method.feature
@@ -0,0 +1,18 @@
+Feature: Spy on an unstubbed method
+
+ Using have_received on an unstubbed method will never pass, so issue a
+ helpful error message.
@myronmarston Owner

The way this is phrased makes issue a helpful message sound like something we are instructing the user to do. What do you think about changing the phrasing to so rspec-mocks issues a helpful error message?

Also, backticks around have_received will make it format better on relish.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
lib/rspec/mocks/example_methods.rb
@@ -109,6 +109,20 @@ def hide_const(constant_name)
ConstantMutator.hide(constant_name)
end
+ # Spy on the given double after expected invocations have occurred.
@myronmarston Owner

I think this phrasing is a bit confusing...as I understand the terminology, have_received doesn't spy on a double, it verifies received messages on a double you are already spying on. I would say that you setup the spying by stubbing a method. Is my terminology/understanding in line with bourne?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
lib/rspec/mocks/have_received.rb
@@ -0,0 +1,59 @@
+module RSpec
+ module Mocks
+ class HaveReceived
+ CONSTRAINTS = %w(
+ exactly at_least at_most times any_number_of_times once twice with
@myronmarston Owner

We've been discussing deprecating any_number_of_times since should_receive(:foo).any_number_of_times no longer causes anything to be verified--it's a method stub in a mock's clothing (see #131). Given that, I'd prefer not to add support for it to a new feature like this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@myronmarston myronmarston commented on the diff
lib/rspec/mocks/have_received.rb
((21 lines not shown))
+ end
+
+ def failure_message
+ generate_failure_message
+ end
+
+ def negative_failure_message
+ generate_failure_message
+ end
+
+ CONSTRAINTS.each do |expectation|
+ define_method expectation do |*args|
+ @constraints << [expectation, *args]
+ self
+ end
+ end
@myronmarston Owner

I like the simplicity with which you added the constraint support here :).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
lib/rspec/mocks/method_double.rb
@@ -221,6 +221,12 @@ def add_negative_expectation(error_generator, expectation_ordering, expected_fro
end
# @private
+ def build_expectation(error_generator, expectation_ordering)
+ expected_from = caller(1)[0]
@myronmarston Owner

You mentioned that this backtrace line is ignored, but this line of code doesn't communicate that very well...it looks like 1 and 0 are specific values that are needed here, when they don't really matter, right?

What do you think about defining a constant like IGNORE_THIS_BACKTRACE_LINE = 'this backtrace line is ignored' and passing that? That would communicate better, I think.

@jferris
jferris added a note

I made this change, but I think a potentially better long-term solution is to update MessageExpectation so that it doesn't take that argument when it won't be used. The property is only used in verify_messages_received, which also isn't used by have_received. We may be able to split up this object in some way to remove that responsibility entirely from the basic case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
lib/rspec/mocks/methods.rb
@@ -121,6 +121,11 @@ def rspec_reset
__mock_proxy.reset
end
+ # @private
+ def __mock_expectation(method_name, &block)
+ __mock_proxy.build_expectation(method_name, &block)
+ end
@myronmarston Owner

Since methods in this module get added to every object in the system, I'm always wary to add additional methods here. Normally I would like having a delegating method like this, but I think the downside of it being added to every object outweighs it in my mind, and I would lean towards removing this method and just using __mock_proxy.build_expectation(method_name, &block) at the call site.

Thoughts?

@jferris
jferris added a note

I could do that, but __mock_proxy is currently marked as private, so we'd either have to move it into the public API for every object, or use send in the matcher to get around it. Thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
spec/rspec/mocks/have_received_spec.rb
((38 lines not shown))
+ matcher.matches?(double)
+ message = matcher.failure_message
+ expect(message).to include('expected: 1 time')
+ end
+ end
+
+ context "negative_failure_message" do
+ it "includes the failed expectation" do
+ double = double_with_met_expectation(:expected_method)
+ matcher = have_received(:expected_method)
+ matcher.does_not_match?(double)
+ message = matcher.negative_failure_message
+ expect(message).to include('expected: 0 times')
+ expect(message).to include('received: 1 time')
+ end
+ end
@myronmarston Owner

These examples are very focused on specifying how this matcher responds to the matcher protocol, which works OK, but I tend to favor examples that simply use the matcher as an end user would. For example:

module RSpec
  module Mocks
    describe HaveReceived do
      describe "expect(...).to have_received" do
        it 'passes when the double has received the given message' do
          dbl = double_with_met_expectation(:expected_method)
          expect(dbl).to have_received(:expected_method)
        end

        it 'fails when the double has not received the given message' do
          dbl = double_with_unmet_expectation(:expected_method)

          expect {
            expect(dbl).to have_received(:expected_method)
          }.to raise_error(/expected: 1 time/)
        end
      end

      describe "expect(...).not_to have_received" do
        it 'passes when the double has not received the given message' do
          dbl = double_with_unmet_expectation(:expected_method)
          expect(dbl).not_to have_received(:expected_method)
        end

        it 'fails when the double has received the given message' do
          dbl = double_with_met_expectation(:expected_method)

          expect {
            expect(dbl).not_to have_received(:expected_method)
          }.to raise_error(/expected: 0 times.*received: 1 time/m)
        end
      end
    end
  end
end

It still tests failure_message, negative_failure_message, matches? and does_not_match? by executing all these methods but treats the as an implementation detail (as I would consider them).

Is there any reason you prefer the approach you've followed here?

@jferris
jferris added a note

I originally had it that way, but I made the same mistake a few times having it exercise the wrong methods. It was also slightly harder to directly test failure messages that way.

For example, when testing the negative case for matches?, it feels natural to use #not_to, but that actually exercises does_not_match? instead. After confusing myself that way several times, I rewrote it to directly call the methods being exercised.

I can restructure this if you'd like, though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
spec/rspec/mocks/have_received_spec.rb
((75 lines not shown))
+ double = double('double', some_method: true)
+ expectation =
+ double('message_expectation', expected_messages_received?: true)
+ double.
+ should_receive(:__mock_expectation).
+ with(:some_method).
+ and_yield(expectation).
+ and_return(expectation)
+ expectation.should_receive(constraint).with(:expected, :args)
+
+ have_received(:some_method).
+ send(constraint, :expected, :args).
+ matches?(double)
+ end
+ end
+ end
@myronmarston Owner

This seems even more tied to the implementation than the above specs -- I think I'd prefer to see a few examples that actually use these constraints as an end user would.

@jferris
jferris added a note

I wrote the first example this way (with), but it's really re-testing functionality that's tested and implemented elsewhere. We just delegate methods like exactly, at_least, and so on, so it seemed like overkill to retest them all here.

If you disagree, I could extract methods or shared example groups from the existing tests and reuse them here to avoid duplication. What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
spec/rspec/mocks/have_received_spec.rb
((89 lines not shown))
+ end
+ end
+
+ def double_with_met_expectation(method_name, *args)
+ double = double_with_unmet_expectation(method_name)
+ double.send(method_name, *args)
+ double
+ end
+
+ def double_with_unmet_expectation(method_name)
+ double('double', method_name => true)
+ end
+
+ def have_received(method_name)
+ HaveReceived.new(method_name)
+ end
@myronmarston Owner

Given the fact that you've also defined have_received in RSpec::Mocks::ExampleMethods, why is this needed here?

@jferris
jferris added a note

You're right - it's not. I added this early on in the commit before defining the matcher properly and forgot to remove it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
spec/rspec/mocks/mock_spec.rb
((7 lines not shown))
+ double.some_method
+ expectation = double.__mock_expectation(:some_method) {}
+ expect(expectation.expected_messages_received?).to be_true
+ end
+
+ it 'returns an unmet expectation when expected messages are not received' do
+ double = double('double', some_method: true, other_method: true)
+ double.other_method
+ expectation = double.__mock_expectation(:some_method) {}
+ expect(expectation.expected_messages_received?).to be_false
+ end
+
+ it 'raises for an unstubbed method' do
+ double = double('double')
+ expect { double.__mock_expectation(:some_method) {} }.
+ to raise_error(MockExpectationError, /some_method.*stubbed/)
@myronmarston Owner

This is a totally subject style thing (and this isn't a merge blocker or anything), but I'm curious why you chose this formatting over:

expect {
  double.__mock_expectation(:some_method) {}
}.to raise_error(MockExpectationError, /some_method.*stubbed/)

I find the indented to at the start of the line to look very odd, and a bit divorced from the expect().to expression, vs the multiline/nested approach retains the unity of the expression better (to me anyway).

@jferris
jferris added a note

There's something about the flow with the exercise on one line (expect { ... }) and the verification on the next (to ...) that reads well to me.

I don't feel strongly about it, though; I'll reformat this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@myronmarston myronmarston commented on the diff
lib/rspec/mocks/proxy.rb
((4 lines not shown))
+ def build_expectation(method_name)
+ meth_double = method_double[method_name]
+ unless meth_double.stubs.any?
+ @error_generator.raise_expectation_on_unstubbed_method(method_name)
+ end
+
+ expectation = meth_double.build_expectation(
+ @error_generator,
+ @expectation_ordering
+ )
+
+ yield expectation
+
+ replay_received_message_on expectation
+ expectation
+ end
@myronmarston Owner

It took me a bit to wrap my head around this, but I like how you've done this--it's very simple and consistent!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
features/spies/spy_stubbed_method.feature
((17 lines not shown))
+ When I run `rspec verified_spy_spec.rb`
+ Then the examples should all pass
+
+ Scenario: fail to verify a stubbed method
+ Given a file named "failed_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "fails when the expectation is not met" do
+ invitation = double('invitation', deliver: true)
+ invitation.should have_received(:deliver)
+ end
+ end
+ """
+ When I run `rspec failed_spy_spec.rb`
+ Then the output should contain "expected: 1 time"
+ And the output should contain "received: 0 times"
@myronmarston Owner

It's not clear from these scenarios (which get published as docs on relish, so the documentation aspect matters a lot here) that the have_received matcher supports the same with(:some, :args).at_least(2).times kind of stuff that is supported with should_receive. i think it would be beneficial to have an example that demonstrates that.

@jferris
jferris added a note

I added a couple more examples, but let me know if you'd be interested in seeing more. I'm trying to strike a balance between good coverage that generates good documentation and over-testing that slows down the suite and just adds more code to maintain.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@myronmarston myronmarston commented on the diff
lib/rspec/mocks/example_methods.rb
@@ -109,6 +109,20 @@ def hide_const(constant_name)
ConstantMutator.hide(constant_name)
end
+ # Spy on the given double after expected invocations have occurred.
+ #
+ # @param method_name [Symbol] name of the method expected to have been
+ # called.
+ #
+ # @example
+ #
+ # invitation = double('invitation', accept: true)
+ # user.accept_invitation(invitation)
+ # expect(invitation).to have_received(:accept)
@myronmarston Owner

Same here -- given that these comments get published as API docs, it'd be great for these to mention that you can chain the same fluent interface off of have_received as you can off of should_receive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@myronmarston

I looked it all over and this is really great. This is a far cleaner implementation than I would have come up with, honestly, so great work! I left lots of comments and would like to see it addressed, but we can always address soem of it post-merge, depending on how much time you have to follow up.

I resurrected some invocation recording functionality in received_messages? that seems to have been somewhat broken for a while. It was added in 2007 for ZenTest support, and seems to have been moved forward with every release without modification since then. Previously it only ever recorded messages for null object mocks; I updated it to record messages for any stubbed method.

Funny, I didn't even know we had received_messages?, and I have no idea what it has to do with ZenTest! I wonder if we should deprecate it with the plan to remove it in 3.0. Thoughts?

The matcher works in negative cases using should_not/not_to; I decided not to implement never, because combining should_not and never has proven to be confusing to bourne users. I figured it would be easier to just have one, standard way to write negative expectations.

I agree 100% with you here. We already have matcher negation; no need to provide a second way to do negation and potentially create confusion.

Two other final comments (that didn't really fit inline in the code):

  • I think we don't want to support have_received when used with a method that has been mocked (using should_receive)-- there's no reason to support that and it would just create confusion. I noticed that your logic already handles this properly (by only looking for a stub using meth_double.stubs.any?), but this is an important enough behavior that I'd like a spec for it, to help guard against future regressions.
  • Do you intend this to work with partial mocks? I don't see anything that disallows this, but there are no specs demonstrating that this works, either. If you intend to support it, it'd be nice to have at least an example or two for that case.
@jferris

Thanks for looking over everything here. I haven't been able to spend the time I need to move this forward, but I wanted to let you know that I've read the comments and agree with your suggestions. I'll try to push a revised version in the next few days and comment again.

@myronmarston

Sounds good. Thanks, @jferris!

@agrobbin

This is a great improvement! However, when running off of your version of rspec-mocks, @jferris , I am running into a problem where the received counts are not being reset on class methods. As an example:

class TestClass; end

describe TestClass do
  before { TestClass.stub(:blah) }

  it "should call TestClass twice" do
    TestClass.blah
    TestClass.blah
    expect(TestClass).to have_received(:blah).exactly(2).times
  end

  it "should call TestClass once" do
    expect(TestClass).to_not have_received(:blah)
  end
end

The result when running rspec test_class_spec.rb is this:

.F

Failures:

  1) TestClass should call TestClass once
     Failure/Error: expect(TestClass).to_not have_received(:blah)
       (<TestClass (class)>).blah(any args)
           expected: 0 times
           received: 2 times
     # ./test_class_spec.rb:13:in `block (2 levels) in <top (required)>'

Finished in 0.00109 seconds
2 examples, 1 failure

Failed examples:

rspec ./test_class_spec.rb:12 # TestClass should call TestClass once

It looks like the same kind of issue that technicalpickles/rspec-spies#18 fixes, if that helps at all.

@jferris

I responded to a number of these comments and pushed a few updates. I'm out of time to work on this for now, but I'll come back and check on the rest soon.

@myronmarston

Thanks for the updates, @jferris! Looks like it can't be automerged anymore, but I want to get this merged so I'll take a stab at merging it this weekend.

@agrobbin -- thanks for reporting that bug -- can you open an issue for it?

myronmarston added some commits
@myronmarston myronmarston Merge branch 'master' into thoughtbot-jf-jq-spies
Conflicts:
	lib/rspec/mocks/framework.rb
658eacb
@myronmarston myronmarston Refactor specs to use public instead of private APIs.
- Just use the have_received matcher directly as its intended
  to be used, rather than invoking `matches?`, `does_not_match?`,
  etc. directly.
- Don't test `__mock_expectation` directly. I'm planning to refactor
  things to remove this method, anyway.
- Going through this exercise surfaced a few issues:
  - The failure messages when `with`, `at_least` or `at_most`
    constraints are used are quite confusing and should be improved.
    I left some TODOs in the specs to remind us to come back
    and revisit.
  - The count constraints create confusion when used with a
    negative expectation (e.g.
    `expect(dbl).not_to have_received(:a).at_most(3).times`
    is confusing and isn't specifying much. We should disallow
    them. I left a pending spec for this so we don't forget.
- These changes were necessary to enable the removal of
  `__mock_expectation` (in my next commit) -- the current ones
  were coupled to the implementation, and did not allow that
  refactoring.
4fcd9ef
@myronmarston myronmarston Remove __mock_expectation.
We want to limit the number of methods added to all object.
The use of `__send__` to get around this is a code smell
but I believe it's preferable to polluting every object with
an extra method.

This was also necessary to get the spec I added in 512bbbf to pass.
55ad2db
@myronmarston

OK, I spent a bit of time getting this in a closer state to merge tonight. I pushed my work into the thoughtbot-jf-jq-spies branch. Things of note:

  • I wanted to remove __mock_expectation, as I mentioned above, as it's important to me not to pollute all objects further. I had to use __send__ to get around the privacy of __mock_proxy, which is a code smell, but I think it's the right trade off. This was necessary to get things green against the recent spec I added in 512bbbf. Also, I'm working on moving the mock proxy out of the object in prep for #153, so this gets it more inline with that refactoring I've been working on.
  • In order to remove __mock_expectation, I had to rework the specs so that they used just the public APIs and didn't assume __mock_expectation was part of the implementation. Going through that exercise actually revealed a couple of issues I hadn't thought of or noticed yet -- see my commit message in 4fcd9ef for the details. It's important we address that stuff before releasing this, but I'm OK merging w/o solving that for now (although if we can get it in before a merge that would be great, too...but I don't want this to sit for too long and get stale).
  • While I was working on this, I thought of another case we haven't discussed: double.as_null_object. Should such an object be considered to be spying on every message, so that you don't actually have to stub anything before using the have_received matcher? My vote is yes, but we should see how feasible that is, make sure we have specs to cover it and document it.
  • There are two other behaviors I mentioned above we should make sure to have specs for:
    • Partial mocks
    • A good error message when you use have_received(:msg) and :msg was previously mocked.

@jferris -- I'm happy to let you do as much or as little on this as you want, so let me know what your plans are here, and whether or not I should try to wrap this up myself this coming week. If you do want to keep working on this, hopefully it's easy to pick up off of my branch; I made sure to merge (rather than rebase) so that it would be easy for you to work off of.

@myronmarston

Also, looks like the travis build is failing:

https://travis-ci.org/rspec/rspec-mocks/builds/5735670

I think you might have used the ruby 1.9 hash syntax in some of the cukes, which is causing syntax errors on the 1.8 builds.

@jferris

@myronmarston I spent some more time on this today and pushed new commits to my branch (after merging your changes).

I think the only remaining issues are the confusing messages when using count expectations. It's currently using the same messages as should_receive, and I'm not sure how it should be different. I you let me know what kind of messages you would expect, I can change them. If it's easier for you to just change them than to explain, that's also cool with me.

Thanks for your help with this, and let me know if I can do anything else to help move this forward.

@myronmarston

Thanks, @jferris!

Off the top of my head, I'd say the messages rspec-mocks emits now for failures related to should_receive().with or should_receive().exactly(x).times, etc, are pretty good, so maybe we can mirror those?

@agrobbin

Hey guys, I just want to make sure that the issue I filed stubbed class method received counts, #248, is figured out before this gets released.

@myronmarston

@agrobbin -- the bug you reported is definitely on my radar. I don't want to release this before that gets fixed, either. I don't want to hold off too long on merging this PR, though, so I suspect we'll probably tackle that as a separate thing after this gets merged, but before cutting the next release (unless @jferris just happens to getting around to fixing it as part of this PR).

@jferris jferris Clear messages received when mocks reset
* Proxy objects for class methods/global objects persist between test runs

Fixes #248.
3cb4ccc
@agrobbin

Looks like @jferris just fixed it, this is great, thanks!

@jferris

@myronmarston I fixed the reset bug and improved the error message for unexpected arguments.

The old messages looked like:

     Failure/Error: expect(dbl).to have_received(:expected_method).with(:unexpected, :args)
       (Double "double").expected_method(:unexpected, :args)
           expected: 1 time
           received: 0 times

The new messages look like:

     Failure/Error: invitation.should have_received(:deliver).with(:expected, :arguments)
       Double "invitation" received :deliver with unexpected arguments
         expected: (:expected, :arguments)
              got: (:unexpected)

I looked through the remaining TODO comments, and the messages look exactly like the should_receive messages. Here are the comparisons for the remaining TODOs:

didn't expect arguments, but got them anyway:

     Failure/Error: expect(dbl).not_to have_received(:expected_method).with(:expected, :args)
       (Double).expected_method(:expected, :args)
           expected: 0 times
           received: 1 time


     Failure/Error: dbl.expected_message(:expected_args)
       (Double).expected_message(:expected_args)
           expected: 0 times
           received: 1 time

not enough calls:

     Failure/Error: expect(dbl).to have_received(:expected_method).at_least(4).times
       (Double).expected_method(any args)
           expected: 4 times
           received: 3 times

     Failure/Error: dbl.should_receive(:expected_method).at_least(4).times
       (Double).expected_method(any args)
           expected: 4 times
           received: 3 times

too many calls:

     Failure/Error: expect(dbl).to have_received(:expected_method).at_most(2).times
       (Double).expected_method(no args)
           expected: 2 times
           received: 3 times

     Failure/Error: 3.times { dbl.expected_method }
       (Double).expected_method(no args)
           expected: 2 times
           received: 3 times

Is there something else that should change there?

@myronmarston myronmarston merged commit 138d298 into from
@myronmarston

@jferris -- thanks for your hard work on this! I just merged it. It'll be in rspec 2.14.

As for the failure messages -- you're right, I had never noticed that the normal error messages for these cases are a bit inaccurate/confusing. Since it's an existing issue, I've opened tickets for those (#252 and #253).

I also noticed that the matcher lacks a description (used in it { should ... } one-liners). I opened #254 so we don't forget that.

Obviously, you're welcome to take a stab at any of these, but don't feel obligated. Your commitment to following through on this PR and responding to all my feedback is admirable!

@jferris

Awesome, thanks!

I opened a pull request for #254. I'll leave #252 and #253 up to you.

@jfelchner

@jferris freaking great job! Can't wait to use this. Thanks again!

@myronmarston myronmarston referenced this pull request
Merged

New syntax #266

@clemens
Collaborator

We're working to ship a 2.14.rc1 with this feature soon.

Awesome!

@searls

This is awesome to see. I wrote gimme exactly because I really think test spies result in clearer tests by preserving arrange-act-assert order (makes it easier to DRY specs with nesting, as well). Spies usually have better error messages / line numbers too.

Huge :+1: Thanks @jferris!!

@adarsh adarsh referenced this pull request from a commit in adarsh/rspec-mocks
@adarsh adarsh Add documentation for Spies in README
* Add documentation and code example for Test Spies implemented in PR #241
* Original PR rspec#241
5e19fe9
@adarsh

Copied the code example to the README.md in this PR: #296

@patrickmcmichael

Trying to leverage this new rspec spies capability for some code I'm writing now, but must be missing something. Any help would be appreciated. Two additional notes...running in jruby, java method to be verified on double returns void...

Using 2.14.0.rc1 of rspec gems:
Installing rspec-core (2.14.0.rc1)
Installing rspec-expectations (2.14.0.rc1)
Installing rspec-mocks (2.14.0.rc1)
Installing rspec (2.14.0.rc1)

Relevant code snippets:

my_double = double("my_double") 
.
.
.
 my_double.stub(:do_something)
.
.
.
[ call to code which ultimately calls :do_something on my_double]
.
.
.
my_double.should have_received(:do_something)

This results in the following:

Double "my_double" received unexpected message :has_received? with (:do_something)

@myronmarston

@patrickmcmichael -- thanks for trying this out. An example like what you've pasted above works for us (you can see the relish examples, that are green for us).

Can you come up with a reproducible example we can work with?

@patrickmcmichael

@jferris and @myronmarston I actually coded it based on what I saw in the excellent relish examples for it. Struggled w/ this last week until realizing that it was a new feature in 2.14, which I upgraded all our gems to this morning, excited (I thought) to move from red to green.

The code itself is proprietary, so I can't share that directly, hence my code equivalent of NAT above. But it does seem to be following the relish examples. The only differences I can think of are the following:

  • running in JRuby and double is standing in for a Java class
  • the method I stub is a java method that returns void, hence why my stub doesn't specify a return value

As the error produced is around the double not being told to expect :has_received?(:do_something), presumably from my ...should have_received(...) call, my question is, where in the new spy support code does the double get told to handle a possible verification via have_received? Is there a chance that since my stub of the void return java method does not specify either a return value nor an exception to raise, that the double isn't told to fully prep for an upcoming have_received check? Obviously this isn't an expectation we'd want to code, it'd be handled under the hood.

@patrickmcmichael

@jferris and @myronmarston The method I'm stubbing/verifying via the double, incidentally, takes 3 args: String, List, String. However, as it returns void and as the inbound arguments are incidental at best to my test, I've stubbed it simply as

my_double.stub(:do_something)

specifying no with clauses.

Not sure if that has anything to do w/ should have_received check resulting in a complaint about unexpected :has_received?(:do_something) error...?

@myronmarston

The reason has_received? is being sent to your double is because rspec-expectations translates have_foo(:some_arg) into has_foo?(:some_arg):

https://github.com/rspec/rspec-expectations/blob/v2.14.0.rc1/lib/rspec/matchers/built_in/has.rb#L28-L30

This is similar to the more well known expect(foo).to be_bar translating into foo.bar?.

It's triggered via method_missing:

https://github.com/rspec/rspec-expectations/blob/v2.14.0.rc1/lib/rspec/matchers/method_missing.rb#L8

...which suggests that have_recieved isn't defined for some reason, even though we can see it right there in the source.

One thought: are you absolutely sure that you're using rspec-mocks 2.14.0.rc1? Could an older version be on the load path some how?

@patrickmcmichael

@myronmarston Let me try to verify that.

My Gemfile has:

gem 'rspec', '2.14.0.rc1' 
gem 'cucumber', '1.2.1' 
gem 'activesupport', '3.2.8'
gem 'i18n', '0.6.0' 

gem 'sequel', '3.39.0'
gem 'tzinfo'

And after bundler the Gemfile.lock has:

GEM
  specs:
    activesupport (3.2.8)
      i18n (~> 0.6)
      multi_json (~> 1.0)
    builder (3.1.4)
    cucumber (1.2.1)
      builder (>= 2.1.2)
      diff-lcs (>= 1.1.3)
      gherkin (~> 2.11.0)
      json (>= 1.4.6)
    diff-lcs (1.1.3)
    gherkin (2.11.2-java)
      json (>= 1.4.6)
    i18n (0.6.0)
    json (1.6.1-java)
    multi_json (1.3.7)
    rspec (2.14.0.rc1)
      rspec-core (= 2.14.0.rc1)
      rspec-expectations (= 2.14.0.rc1)
      rspec-mocks (= 2.14.0.rc1)
    rspec-core (2.14.0.rc1)
    rspec-expectations (2.14.0.rc1)
      diff-lcs (>= 1.1.3, < 2.0)
    rspec-mocks (2.14.0.rc1)
    sequel (3.39.0)
    tzinfo (0.3.9)

PLATFORMS
  java

DEPENDENCIES
  activesupport (= 3.2.8)
  cucumber (= 1.2.1)
  i18n (= 0.6.0)
  rspec (= 2.14.0.rc1)
  sequel (= 3.39.0)
  tzinfo
@patrickmcmichael

@myronmarston @jferris Thanks for all the help. While my gemfile was good, there was some funkiness around eclipse tooling GEM_HOME refs, which once hard-overridden to the directory which contained the versions from my GemFile, worked like a charm.

@myronmarston

Glad you sorted it out :).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Mar 15, 2013
  1. @jferris

    Add test spies

    Joe Ferris and Joël Quenneville authored jferris committed
    * Adds have_received matcher
    * Extends Proxy/MethodDouble/MessageExpectation
    * Records all invocations on stubbed methods
Commits on Mar 22, 2013
  1. @jferris

    Update documentation

    jferris authored
  2. @jferris

    Remove any_number_of_times

    jferris authored
  3. @jferris
  4. @jferris
  5. @jferris

    Reformat expectation

    jferris authored
  6. @jferris
  7. @jferris
Commits on Mar 23, 2013
  1. @myronmarston

    Merge branch 'master' into thoughtbot-jf-jq-spies

    myronmarston authored
    Conflicts:
    	lib/rspec/mocks/framework.rb
  2. @myronmarston

    Refactor specs to use public instead of private APIs.

    myronmarston authored
    - Just use the have_received matcher directly as its intended
      to be used, rather than invoking `matches?`, `does_not_match?`,
      etc. directly.
    - Don't test `__mock_expectation` directly. I'm planning to refactor
      things to remove this method, anyway.
    - Going through this exercise surfaced a few issues:
      - The failure messages when `with`, `at_least` or `at_most`
        constraints are used are quite confusing and should be improved.
        I left some TODOs in the specs to remind us to come back
        and revisit.
      - The count constraints create confusion when used with a
        negative expectation (e.g.
        `expect(dbl).not_to have_received(:a).at_most(3).times`
        is confusing and isn't specifying much. We should disallow
        them. I left a pending spec for this so we don't forget.
    - These changes were necessary to enable the removal of
      `__mock_expectation` (in my next commit) -- the current ones
      were coupled to the implementation, and did not allow that
      refactoring.
  3. @myronmarston

    Remove __mock_expectation.

    myronmarston authored
    We want to limit the number of methods added to all object.
    The use of `__send__` to get around this is a code smell
    but I believe it's preferable to polluting every object with
    an extra method.
    
    This was also necessary to get the spec I added in 512bbbf to pass.
Commits on Mar 26, 2013
  1. @jferris
  2. @jferris
  3. @jferris

    Fix spy failures on 1.8

    jferris authored
  4. @jferris
  5. @jferris
Commits on Mar 27, 2013
  1. @jferris

    Clear messages received when mocks reset

    jferris authored
    * Proxy objects for class methods/global objects persist between test runs
    
    Fixes #248.
  2. @jferris
This page is out of date. Refresh to see the latest.
View
34 features/spies/spy_partial_mock_method.feature
@@ -0,0 +1,34 @@
+Feature: Spy on a stubbed method on a partial mock
+
+ You can also use `have_received` to verify that a stubbed method was invoked
+ on a partial mock.
+
+ Scenario: verify a stubbed method
+ Given a file named "verified_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "passes when the expectation is met" do
+ invitation = Object.new
+ invitation.stub(:deliver => true)
+ invitation.deliver
+ invitation.should have_received(:deliver)
+ end
+ end
+ """
+ When I run `rspec verified_spy_spec.rb`
+ Then the examples should all pass
+
+ Scenario: fail to verify a stubbed method
+ Given a file named "failed_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "fails when the expectation is not met" do
+ invitation = Object.new
+ invitation.stub(:deliver => true)
+ invitation.should have_received(:deliver)
+ end
+ end
+ """
+ When I run `rspec failed_spy_spec.rb`
+ Then the output should contain "expected: 1 time"
+ And the output should contain "received: 0 times"
View
63 features/spies/spy_pure_mock_method.feature
@@ -0,0 +1,63 @@
+Feature: Spy on a stubbed method on a pure mock
+
+ You can use `have_received` to verify that a stubbed method was invoked,
+ rather than setting an expectation for it to be invoked beforehand.
+
+ Scenario: verify a stubbed method
+ Given a file named "verified_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "passes when the expectation is met" do
+ invitation = double('invitation', :deliver => true)
+ invitation.deliver
+ invitation.should have_received(:deliver)
+ end
+ end
+ """
+ When I run `rspec verified_spy_spec.rb`
+ Then the examples should all pass
+
+ Scenario: verify a stubbed method with message expectations
+ Given a file named "verified_message_expectations_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "passes when the expectation is met" do
+ invitation = double('invitation', :deliver => true)
+ 2.times { invitation.deliver(:expected, :arguments) }
+ invitation.should have_received(:deliver).
+ with(:expected, :arguments).
+ twice
+ end
+ end
+ """
+ When I run `rspec verified_message_expectations_spec.rb`
+ Then the examples should all pass
+
+ Scenario: fail to verify a stubbed method
+ Given a file named "failed_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "fails when the expectation is not met" do
+ invitation = double('invitation', :deliver => true)
+ invitation.should have_received(:deliver)
+ end
+ end
+ """
+ When I run `rspec failed_spy_spec.rb`
+ Then the output should contain "expected: 1 time"
+ And the output should contain "received: 0 times"
+
+ Scenario: fail to verify message expectations
+ Given a file named "failed_message_expectations_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "fails when the arguments are different" do
+ invitation = double('invitation', :deliver => true)
+ invitation.deliver(:unexpected)
+ invitation.should have_received(:deliver).with(:expected, :arguments)
+ end
+ end
+ """
+ When I run `rspec failed_message_expectations_spec.rb`
+ Then the output should contain "expected: (:expected, :arguments)"
+ And the output should contain "got: (:unexpected)"
View
18 features/spies/spy_unstubbed_method.feature
@@ -0,0 +1,18 @@
+Feature: Spy on an unstubbed method
+
+ Using have_received on an unstubbed method will never pass, so rspec-mocks
+ issues a helpful error message.
+
+ Scenario: fail to verify a stubbed method
+ Given a file named "failed_spy_spec.rb" with:
+ """ruby
+ describe "have_received" do
+ it "raises a helpful error for unstubbed methods" do
+ object = Object.new
+ object.object_id
+ object.should have_received(:object_id)
+ end
+ end
+ """
+ When I run `rspec failed_spy_spec.rb`
+ Then the output should contain "that method has not been stubbed"
View
14 lib/rspec/mocks/error_generator.rb
@@ -72,6 +72,18 @@ def raise_only_valid_on_a_partial_mock(method)
"available on a partial mock object."
end
+ # @private
+ def raise_expectation_on_unstubbed_method(method)
+ __raise "#{intro} expected to have received #{method}, but that " +
+ "method has not been stubbed."
+ end
+
+ # @private
+ def raise_expectation_on_mocked_method(method)
+ __raise "#{intro} expected to have received #{method}, but that " +
+ "method has been mocked instead of stubbed."
+ end
+
private
def intro
@@ -116,4 +128,4 @@ def pretty_print(count)
end
end
-end
+end
View
22 lib/rspec/mocks/example_methods.rb
@@ -109,6 +109,28 @@ def hide_const(constant_name)
ConstantMutator.hide(constant_name)
end
+ # Verifies that the given object received the expected message during the
+ # course of the test. The method must have previously been stubbed in
+ # order for messages to be verified.
+ #
+ # Stubbing and verifying messages received in this way implements the
+ # Test Spy pattern.
+ #
+ # @param method_name [Symbol] name of the method expected to have been
+ # called.
+ #
+ # @example
+ #
+ # invitation = double('invitation', accept: true)
+ # user.accept_invitation(invitation)
+ # expect(invitation).to have_received(:accept)
@myronmarston Owner

Same here -- given that these comments get published as API docs, it'd be great for these to mention that you can chain the same fluent interface off of have_received as you can off of should_receive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ #
+ # # You can also use most message expectations:
+ # expect(invitation).to have_received(:accept).with(mailer).once
+ def have_received(method_name)
+ HaveReceived.new(method_name)
+ end
+
private
def declare_double(declared_as, *args)
View
1  lib/rspec/mocks/framework.rb
@@ -20,5 +20,6 @@
require 'rspec/mocks/serialization'
require 'rspec/mocks/any_instance'
require 'rspec/mocks/mutate_const'
+require 'rspec/mocks/have_received'
require 'rspec/mocks/stub_chain'
View
84 lib/rspec/mocks/have_received.rb
@@ -0,0 +1,84 @@
+module RSpec
+ module Mocks
+ class HaveReceived
+ COUNT_CONSTRAINTS = %w(exactly at_least at_most times once twice)
+ ARGS_CONSTRAINTS = %w(with)
+ CONSTRAINTS = COUNT_CONSTRAINTS + ARGS_CONSTRAINTS
+
+ def initialize(method_name)
+ @method_name = method_name
+ @constraints = []
+ end
+
+ def matches?(subject)
+ @subject = subject
+ @expectation = expect
+ @expectation.expected_messages_received?
+ end
+
+ def does_not_match?(subject)
+ @subject = subject
+ ensure_count_unconstrained
+ @expectation = expect.never
+ @expectation.expected_messages_received?
+ end
+
+ def failure_message
+ generate_failure_message
+ end
+
+ def negative_failure_message
+ generate_failure_message
+ end
+
+ CONSTRAINTS.each do |expectation|
+ define_method expectation do |*args|
+ @constraints << [expectation, *args]
+ self
+ end
+ end
@myronmarston Owner

I like the simplicity with which you added the constraint support here :).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+
+ private
+
+ def expect
+ build_expectation do |expectation|
+ apply_constraints_to expectation
+ end
+ end
+
+ def apply_constraints_to(expectation)
+ @constraints.each do |constraint|
+ expectation.send(*constraint)
+ end
+ end
+
+ def ensure_count_unconstrained
+ if count_constrait
+ raise RSpec::Mocks::MockExpectationError,
+ "can't use #{count_constrait} when negative"
+ end
+ end
+
+ def count_constrait
+ @constraints.map(&:first).detect do |constraint|
+ COUNT_CONSTRAINTS.include?(constraint)
+ end
+ end
+
+ def generate_failure_message
+ mock_proxy.check_for_unexpected_arguments(@expectation)
+ @expectation.generate_error
+ rescue RSpec::Mocks::MockExpectationError => error
+ error.message
+ end
+
+ def build_expectation(&block)
+ mock_proxy.build_expectation(@method_name, &block)
+ end
+
+ def mock_proxy
+ @subject.__send__(:__mock_proxy)
+ end
+ end
+ end
+end
View
9 lib/rspec/mocks/method_double.rb
@@ -221,6 +221,12 @@ def add_negative_expectation(error_generator, expectation_ordering, expected_fro
end
# @private
+ def build_expectation(error_generator, expectation_ordering)
+ expected_from = IGNORED_BACKTRACE_LINE
+ MessageExpectation.new(error_generator, expectation_ordering, expected_from, self)
+ end
+
+ # @private
def add_stub(error_generator, expectation_ordering, expected_from, opts={}, &implementation)
configure_method
stub = MessageExpectation.new(error_generator, expectation_ordering, expected_from,
@@ -262,6 +268,9 @@ def raise_method_not_stubbed_error
def reset_nil_expectations_warning
RSpec::Mocks::Proxy.warn_about_expectations_on_nil = true if proxy_for_nil_class?
end
+
+ # @private
+ IGNORED_BACKTRACE_LINE = 'this backtrace line is ignored'
end
end
end
View
43 lib/rspec/mocks/proxy.rb
@@ -82,6 +82,38 @@ def add_negative_message_expectation(location, method_name, &implementation)
end
# @private
+ def build_expectation(method_name)
+ meth_double = method_double[method_name]
+
+ if meth_double.expectations.any?
+ @error_generator.raise_expectation_on_mocked_method(method_name)
+ end
+
+ unless null_object? || meth_double.stubs.any?
+ @error_generator.raise_expectation_on_unstubbed_method(method_name)
+ end
+
+ expectation = meth_double.build_expectation(
+ @error_generator,
+ @expectation_ordering
+ )
+
+ yield expectation
+
+ replay_received_message_on expectation
+ expectation
+ end
@myronmarston Owner

It took me a bit to wrap my head around this, but I like how you've done this--it's very simple and consistent!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+
+ # @private
+ def check_for_unexpected_arguments(expectation)
+ @messages_received.each do |(method_name, args, block)|
+ if expectation.matches_name_but_not_args(method_name, *args)
+ raise_unexpected_message_args_error(expectation, *args)
+ end
+ end
+ end
+
+ # @private
def add_stub(location, method_name, opts={}, &implementation)
method_double[method_name].add_stub @error_generator, @expectation_ordering, location, opts, &implementation
end
@@ -101,6 +133,7 @@ def verify
# @private
def reset
method_doubles.each {|d| d.reset}
+ @messages_received.clear
end
# @private
@@ -120,6 +153,7 @@ def record_message_received(message, *args, &block)
# @private
def message_received(message, *args, &block)
+ record_message_received message, *args, &block
expectation = find_matching_expectation(message, *args)
stub = find_matching_method_stub(message, *args)
@@ -200,6 +234,15 @@ def find_matching_method_stub(method_name, *args)
def find_almost_matching_stub(method_name, *args)
method_double[method_name].stubs.find {|stub| stub.matches_name_but_not_args(method_name, *args)}
end
+
+ def replay_received_message_on(expectation)
+ @messages_received.each do |(method_name, args, block)|
+ if expectation.matches?(method_name, *args)
+ expectation.invoke(nil)
+ end
+ end
+ end
+
end
end
end
View
261 spec/rspec/mocks/have_received_spec.rb
@@ -0,0 +1,261 @@
+require 'spec_helper'
+
+module RSpec
+ module Mocks
+ describe HaveReceived do
+ describe "expect(...).to have_received" do
+ it 'passes when the double has received the given message' do
+ dbl = double_with_met_expectation(:expected_method)
+ expect(dbl).to have_received(:expected_method)
+ end
+
+ it 'passes when a null object has received the given message' do
+ dbl = null_object_with_met_expectation(:expected_method)
+ expect(dbl).to have_received(:expected_method)
+ end
+
+ it 'fails when the double has not received the given message' do
+ dbl = double_with_unmet_expectation(:expected_method)
+
+ expect {
+ expect(dbl).to have_received(:expected_method)
+ }.to raise_error(/expected: 1 time/)
+ end
+
+ it 'fails when a null object has not received the given message' do
+ dbl = double.as_null_object
+
+ expect {
+ expect(dbl).to have_received(:expected_method)
+ }.to raise_error(/expected: 1 time/)
+ end
+
+ it 'fails when the method has not been previously stubbed' do
+ dbl = double
+
+ expect {
+ expect(dbl).to have_received(:expected_method)
+ }.to raise_error(/method has not been stubbed/)
+ end
+
+ it 'fails when the method has been mocked' do
+ dbl = double
+ dbl.should_receive(:expected_method)
+ dbl.expected_method
+
+ expect {
+ expect(dbl).to have_received(:expected_method)
+ }.to raise_error(/method has been mocked instead of stubbed/)
+ end
+
+ it 'resets expectations on class methods when mocks are reset' do
+ dbl = Object
+ dbl.stub(:expected_method)
+ dbl.expected_method
+ dbl.__send__(:__mock_proxy).reset
+ dbl.stub(:expected_method)
+
+ expect {
+ expect(dbl).to have_received(:expected_method)
+ }.to raise_error(/0 times/)
+ end
+
+ context "with" do
+ it 'passes when the given args match the args used with the message' do
+ dbl = double_with_met_expectation(:expected_method, :expected, :args)
+ expect(dbl).to have_received(:expected_method).with(:expected, :args)
+ end
+
+ it 'fails when the given args do not match the args used with the message' do
+ dbl = double_with_met_expectation(:expected_method, :expected, :args)
+
+ expect {
+ expect(dbl).to have_received(:expected_method).with(:unexpected, :args)
+ }.to raise_error(/with unexpected arguments/)
+ end
+ end
+
+ context "counts" do
+ let(:dbl) { double(:expected_method => nil) }
+
+ before do
+ dbl.expected_method
+ dbl.expected_method
+ dbl.expected_method
+ end
+
+ context "exactly" do
+ it 'passes when the message was received the given number of times' do
+ expect(dbl).to have_received(:expected_method).exactly(3).times
+ end
+
+ it 'fails when the message was received more times' do
+ expect {
+ expect(dbl).to have_received(:expected_method).exactly(2).times
+ }.to raise_error(/expected: 2 times.*received: 3 times/m)
+ end
+
+ it 'fails when the message was received fewer times' do
+ expect {
+ expect(dbl).to have_received(:expected_method).exactly(4).times
+ }.to raise_error(/expected: 4 times.*received: 3 times/m)
+ end
+ end
+
+ context 'at_least' do
+ it 'passes when the message was received the given number of times' do
+ expect(dbl).to have_received(:expected_method).at_least(3).times
+ end
+
+ it 'passes when the message was received more times' do
+ expect(dbl).to have_received(:expected_method).at_least(2).times
+ end
+
+ it 'fails when the message was received fewer times' do
+ expect {
+ expect(dbl).to have_received(:expected_method).at_least(4).times
+ }.to raise_error(/expected: 4 times.*received: 3 times/m) # TODO: better message
+ end
+ end
+
+ context 'at_most' do
+ it 'passes when the message was received the given number of times' do
+ expect(dbl).to have_received(:expected_method).at_most(3).times
+ end
+
+ it 'passes when the message was received fewer times' do
+ expect(dbl).to have_received(:expected_method).at_most(4).times
+ end
+
+ it 'fails when the message was received more times' do
+ expect {
+ expect(dbl).to have_received(:expected_method).at_most(2).times
+ }.to raise_error(/expected: 2 times.*received: 3 times/m) # TODO: better message
+ end
+ end
+
+ context 'once' do
+ it 'passes when the message was received once' do
+ dbl = double(:expected_method => nil)
+ dbl.expected_method
+ expect(dbl).to have_received(:expected_method).once
+ end
+
+ it 'fails when the message was never received' do
+ dbl = double(:expected_method => nil)
+
+ expect {
+ expect(dbl).to have_received(:expected_method).once
+ }.to raise_error(/expected: 1 time.*received: 0 times/m)
+ end
+
+ it 'fails when the message was received twice' do
+ dbl = double(:expected_method => nil)
+ dbl.expected_method
+ dbl.expected_method
+
+ expect {
+ expect(dbl).to have_received(:expected_method).once
+ }.to raise_error(/expected: 1 time.*received: 2 times/m)
+ end
+ end
+
+ context 'twice' do
+ it 'passes when the message was received twice' do
+ dbl = double(:expected_method => nil)
+ dbl.expected_method
+ dbl.expected_method
+
+ expect(dbl).to have_received(:expected_method).twice
+ end
+
+ it 'fails when the message was received once' do
+ dbl = double(:expected_method => nil)
+ dbl.expected_method
+
+ expect {
+ expect(dbl).to have_received(:expected_method).twice
+ }.to raise_error(/expected: 2 times.*received: 1 time/m)
+ end
+
+ it 'fails when the message was received thrice' do
+ dbl = double(:expected_method => nil)
+ dbl.expected_method
+ dbl.expected_method
+ dbl.expected_method
+
+ expect {
+ expect(dbl).to have_received(:expected_method).twice
+ }.to raise_error(/expected: 2 times.*received: 3 times/m)
+ end
+ end
+ end
+ end
+
+ describe "expect(...).not_to have_received" do
+ it 'passes when the double has not received the given message' do
+ dbl = double_with_unmet_expectation(:expected_method)
+ expect(dbl).not_to have_received(:expected_method)
+ end
+
+ it 'fails when the double has received the given message' do
+ dbl = double_with_met_expectation(:expected_method)
+
+ expect {
+ expect(dbl).not_to have_received(:expected_method)
+ }.to raise_error(/expected: 0 times.*received: 1 time/m)
+ end
+
+ it 'fails when the method has not been previously stubbed' do
+ dbl = double
+
+ expect {
+ expect(dbl).not_to have_received(:expected_method)
+ }.to raise_error(/method has not been stubbed/)
+ end
+
+ context "with" do
+ it 'passes when the given args do not match the args used with the message' do
+ dbl = double_with_met_expectation(:expected_method, :expected, :args)
+ expect(dbl).not_to have_received(:expected_method).with(:unexpected, :args)
+ end
+
+ it 'fails when the given args match the args used with the message' do
+ dbl = double_with_met_expectation(:expected_method, :expected, :args)
+
+ expect {
+ expect(dbl).not_to have_received(:expected_method).with(:expected, :args)
+ }.to raise_error(/expected: 0 times.*received: 1 time/m) # TODO: better message
+ end
+ end
+
+ %w(exactly at_least at_most times once twice).each do |constraint|
+ it "does not allow #{constraint} to be used because it creates confusion" do
+ dbl = double_with_unmet_expectation(:expected_method)
+ expect {
+ expect(dbl).not_to have_received(:expected_method).send(constraint)
+ }.to raise_error(/can't use #{constraint} when negative/)
+ end
+ end
+ end
+
+ def double_with_met_expectation(method_name, *args)
+ double = double_with_unmet_expectation(method_name)
+ meet_expectation(double, method_name, *args)
+ end
+
+ def null_object_with_met_expectation(method_name, *args)
+ meet_expectation(double.as_null_object, method_name, *args)
+ end
+
+ def meet_expectation(double, method_name, *args)
+ double.send(method_name, *args)
+ double
+ end
+
+ def double_with_unmet_expectation(method_name)
+ double('double', method_name => true)
+ end
+ end
+ end
+end
View
2  spec/rspec/mocks/mock_spec.rb
@@ -733,6 +733,6 @@ def add_call
end
end
end
-
end
end
+
Something went wrong with that request. Please try again.