Repository: freerange/mocha (Ruby)

Issue:
`expects.never` doesn't handle `stubs` methods correctly
We found that `expects.never` doesn't fail a test when the method is stubbed with `stubs`. Reproduce example below, with the `test_foo_never_called` case. I don't think this is intended?

```ruby
require 'rubygems'
gem 'mocha'
require 'minitest/autorun'
require 'mocha/minitest'

class TestMeme < Minitest::Test
  class Klass
    def self.foo; end
  end

  def test_foo_never_called # should fail, but doesn't
    Klass.stubs(:foo)
    Klass.expects(:foo).never
    Klass.foo
  end

  def test_foo_called_once # correctly passes
    Klass.stubs(:foo)
    Klass.expects(:foo).once
    Klass.foo
  end

  def test_no_stubs # correctly fails
    Klass.expects(:foo).never
    Klass.foo
  end
end
```

output
```
➜ sandbox ruby mocha_stub.rb
Run options: --seed 21862

# Running:

F

Failure:
TestMeme#test_no_stubs [mocha_stub.rb:25]:
unexpected invocation: TestMeme::Klass.foo()
unsatisfied expectations:
- expected never, invoked once: TestMeme::Klass.foo(any_parameters)



rails test mocha_stub.rb:23

..

Finished in 0.000907s, 3307.6069 runs/s, 2205.0713 assertions/s.
3 runs, 2 assertions, 1 failures, 0 errors, 0 skips
```
cc @zenspider 


Discussion:
This is working as expected and is effectively a duplicate of #490, #131 & #44. However, I recognize that the behaviour is confusing and, having revisited my responses to those issues, I'm now more sympathetic to trying to come up with a fix. 

Previously I'd hoped that fixing #173 would solve this problem, but I now recognize that I'm unlikely to implement that any time soon.

So to that end I've spiked on a fix in #679 which would generate this extra test failure for your example:

```
  1) Failure:
TestMeme#test_foo_never_called [foo.rb:11]:
not all expectations were satisfied
unsatisfied expectations:
- expected never, invoked once: TestMeme::Klass.foo(any_parameters)
satisfied expectations:
- allowed any number of times, invoked never: TestMeme::Klass.foo(any_parameters)
```

**Is that the kind of failure you are expecting?**

Now I just need to find the time to:
1. Work through the test failures in the spike to see if this fix causes collateral damage elsewhere
2. Work out how to roll this out, i.e. deprecation warnings, etc

Unfortunately I don't have loads of time to spend on this at the moment, so it might take me a while, but I'll do my best!
personally I think that failure above is fine. I do find the double entry for `foo` to be confusing but I know enough to know why it is there.

Some of your failure messages are changing in ways that will be less explanatory, eg:

```
  1) Failure:
UnexpectedInvocationTest#test_avoid_recursion_when_unexpected_invocation_exception_message_depends_on_uninspectable_object [test/acceptance/unexpected_invocation_test.rb:21]:
--- expected
+++ actual
@@ -1 +1 @@
-"unexpected invocation: inspect(1, 2, foo)"
+"not all expectations were satisfied"


  2) Failure:
FailureMessageTest#test_should_include_unexpected_invocation_in_unsatisfied_expectation_message [test/acceptance/multiple_expectations_failure_message_test.rb:21]:
--- expected
+++ actual
@@ -1 +1 @@
-["unexpected invocation: #<Mock:mock>.method_one()", "unsatisfied expectations:", "- expected exactly once, invoked twice: #<Mock:mock>.method_one(any_parameters)"]
+["not all expectations were satisfied", "unsatisfied expectations:", "- expected exactly once, invoked twice: #<Mock:mock>.method_one(any_parameters)"]
```
> personally I think that failure above is fine. I do find the double entry for `foo` to be confusing but I know enough to know why it is there.

Thanks for your feedback. I wasn't really asking about the specifics of the error message; I was more interested in double-checking this was the failure that @ducmtran was expecting when they added the "should fail, but doesn't" comment to the `test_foo_never_called` method in their original example.

Making any improvements to the specific of the error message is out-of-scope and would be tackled separately.

> Some of your failure messages are changing in ways that will be less explanatory, eg:

These other tests were failing due to shortcomings in my original spike. I have now come up with a solution that doesn't break any other tests in #679.

@ducmtran @zenspider Would either of you be able to run [this branch](https://github.com/freerange/mocha/compare/main...expectation-with-never-cardinality-should-fail-fast-if-invoked) of Mocha against your test suite to give me some more confidence I haven't broken anything else? Thanks.
I've created a PR which implements a deprecation warning in #681.

@ducmtran @zenspider It would be great if you could try [that branch](https://github.com/freerange/mocha/compare/main...expectation-with-never-cardinality-should-display-deprecation-warning) out too if you have time.
Thanks for the quick responses and the background on this. I see why it is the way it is now, but i'm definitely stumped when running into the test case above

We will try the deprecation branch and see how it goes


@ducmtran 

> We will try the deprecation branch and see how it goes

Thanks - let me know how it goes so I can get it released



Patch:
diff --git a/README.md b/README.md
index c63cd508c..0ee358845 100644
--- a/README.md
+++ b/README.md
@@ -276,7 +276,7 @@ Maybe, but probably not. Partial mocking changes the state of objects in the `Ob
 
 Stubs and expectations are basically the same thing. A stub is just an expectation of zero or more invocations. The `Expectation#stubs` method is syntactic sugar to make the intent of the test more explicit.
 
-When a method is invoked on a mock object, the mock object searches through its expectations from newest to oldest to find one that matches the invocation. After the invocation, the matching expectation might stop matching further invocations.
+When a method is invoked on a mock object, the mock object searches through its expectations from newest to oldest to find one that matches the invocation. After the invocation, the matching expectation might stop matching further invocations. If the expectation that matches the invocation has a cardinality of "never", then an unexpected invocation error is reported.
 
 See the [documentation](https://mocha.jamesmead.org/Mocha/Mock.html) for `Mocha::Mock` for further details.
 
diff --git a/lib/mocha/mock.rb b/lib/mocha/mock.rb
index 4cf5839cf..059dc24d8 100644
--- a/lib/mocha/mock.rb
+++ b/lib/mocha/mock.rb
@@ -35,6 +35,9 @@ module Mocha
   # while an +expects(:foo).at_least_once+ expectation will always be matched
   # against invocations.
   #
+  # However, note that if the expectation that matches the invocation has a
+  # cardinality of "never", then an unexpected invocation error is reported.
+  #
   # This scheme allows you to:
   #
   # - Set up default stubs in your the +setup+ method of your test class and
@@ -326,15 +329,11 @@ def handle_method_call(symbol, arguments, block) # rubocop:disable Metrics/Cyclo
       matching_expectations = all_expectations.matching_expectations(invocation)
 
       index = 0
-      never_allowed_expectation = nil
       while index < matching_expectations.length
         matching_expectation = matching_expectations[index]
         if matching_expectation.invocations_never_allowed?
-          never_allowed_expectation = matching_expectation
+          raise_unexpected_invocation_error(invocation, matching_expectation)
         elsif matching_expectation.invocations_allowed?
-          if never_allowed_expectation
-            invocation_not_allowed_warning(invocation, never_allowed_expectation)
-          end
           return matching_expectation.invoke(invocation)
         end
         index += 1
@@ -387,14 +386,6 @@ def any_expectations?
 
     private
 
-    def invocation_not_allowed_warning(invocation, expectation)
-      messages = [
-        "The expectation defined at #{expectation.definition_location} does not allow invocations, but #{invocation.call_description} was invoked.",
-        'This invocation will cause the test to fail fast in a future version of Mocha.'
-      ]
-      Deprecation.warning(messages.join(' '))
-    end
-
     def raise_unexpected_invocation_error(invocation, matching_expectation)
       if @unexpected_invocation.nil?
         @unexpected_invocation = invocation


Test patch:
diff --git a/test/acceptance/mocked_methods_dispatch_test.rb b/test/acceptance/mocked_methods_dispatch_test.rb
index 594c962e7..af89da127 100644
--- a/test/acceptance/mocked_methods_dispatch_test.rb
+++ b/test/acceptance/mocked_methods_dispatch_test.rb
@@ -1,6 +1,4 @@
 require File.expand_path('../acceptance_test_helper', __FILE__)
-require 'deprecation_disabler'
-require 'execution_point'
 
 class MockedMethodDispatchTest < Mocha::TestCase
   include AcceptanceTest
@@ -75,37 +73,30 @@ def test_should_find_latest_expectation_with_range_of_expected_invocation_count_
     assert_passed(test_result)
   end
 
-  def test_should_display_deprecation_warning_if_invocation_matches_expectation_with_never_cardinality
-    execution_point = nil
+  def test_should_fail_fast_if_invocation_matches_expectation_with_never_cardinality
     test_result = run_as_test do
       mock = mock('mock')
       mock.stubs(:method)
-      mock.expects(:method).never; execution_point = ExecutionPoint.current
-      DeprecationDisabler.disable_deprecations do
-        mock.method
-      end
+      mock.expects(:method).never
+      mock.method
     end
-    assert_passed(test_result)
-    message = Mocha::Deprecation.messages.last
-    location = execution_point.location
-    expected = [
-      "The expectation defined at #{location} does not allow invocations, but #<Mock:mock>.method() was invoked.",
-      'This invocation will cause the test to fail fast in a future version of Mocha.'
-    ]
-    assert_equal expected.join(' '), message
+    assert_failed(test_result)
+    assert_equal [
+      'unexpected invocation: #<Mock:mock>.method()',
+      'unsatisfied expectations:',
+      '- expected never, invoked once: #<Mock:mock>.method(any_parameters)',
+      'satisfied expectations:',
+      '- allowed any number of times, invoked never: #<Mock:mock>.method(any_parameters)'
+    ], test_result.failure_message_lines
   end
 
-  def test_should_not_display_deprecation_warning_if_invocation_matches_expectation_allowing_invocation_before_matching_expectation_with_never_cardinality
+  def test_should_not_fail_fast_if_invocation_matches_expectation_allowing_invocation_before_matching_expectation_with_never_cardinality
     test_result = run_as_test do
       mock = mock('mock')
       mock.expects(:method).never
       mock.expects(:method).once
-      Mocha::Deprecation.messages = []
-      DeprecationDisabler.disable_deprecations do
-        mock.method
-      end
+      mock.method
     end
     assert_passed(test_result)
-    assert Mocha::Deprecation.messages.empty?
   end
 end