RSpec::Mockbidden

Utility gem for RSpec testing framework to forbid mocking methods on objects/classes/modules, evaluated at test runtime.

RSpec.configure do |config|
  config.before do
    forbid_any_instance_of(Hash).from receiving(:fetch)
    # `allow_any_instance_of(Hash).to receive(:fetch)` raises an error
    # also `allow({}).to receive(:fetch)` raises an error

    forbid(ApplicationRecord).from receiving(:find_by)
    # forbids mocking `.find_by` on any class which inherits from `ApplicationRecord`

    forbid(Array).from receiving(anything)
    forbid_any_instance_of(Array).from receiving(anything)
    # no class or instance methods can be mocked on `Array`
  end
end

This gem is in v0 phase, see Motivation. Feedback and contributions welcome!

Installation

Add to your Gemfile which already has rspec:

gem 'rspec-mockbidden', require: false

Require gem in spec_helper.rb/rails_helper.rb:

require 'rspec/mockbidden'

Include Mockbidden methods in your RSpec configuration and assert there are no mock violations with an after example hook:

RSpec.configure do |config|
  config.include RSpec::Mockbidden::Methods

  config.after do
    RSpec::Mockbidden.assert_no_forbidden_mocks!
  end
end

Usage

There are only two methods: forbid and forbid_any_instance_of.

forbid(target).from receiving(method)

Forbids setting up allow/expect on a specific target.

target can be an instance or a class/module. When it's an instance, it forbids mocking its method (it does not affect other instances of the same class). When it's a class/module, it forbids mocking the class method on that class or any of its subclasses (or classes which include the target module).

method is a string/symbol representing the method name.

target and method can also be RSpec's matcher anything, e.g.:

forbid(anything).from receiving(:to_s)
forbid(Hash).from receiving(anything)

When target is anything, mocking is forbidden for any class's class method.

When method is anything, mocking is forbidden for all class methods on target.

You can also use forbid(anything).from receiving(anything) to forbid mocking all class methods on all classes/modules.

forbid_any_instance_of(target).from receiving(method)

Forbids setting up allow/expect/allow_any_instance_of/expect_any_instance_of on all instances of a target.

target can be a class/module. Same inheritance rules apply as above. method is identical to above.

anything can also be used here:

forbid_any_instance_of(anything).from receiving(:to_s)
forbid_any_instance_of(Hash).from receiving(anything)

When target is anything, mocking is forbidden for any instance's method.

When method is anything, mocking is forbidden for all instance methods on target.

forbid_any_instance_of(anything).from receiving(anything) forbids mocking methods on all instances.

Forbidden mocks and and_call_original

When and_call_original is used, e.g.:

forbid(foo).from receiving(:bar)
allow(foo).to receive(:bar).and_call_original

there won't be an error for forbidden mocks. .and_call_original calls the original method implementation, so an error would be a false positive.

.and_wrap_original still raises an error, see Limitations.

Usage in before(:suite) hook

If you want to use forbid in a before(:suite) hook, you'll need to replace config.include with just include. This is because config.include includes a module's methods only in examples, but before(:suite) doesn't run as such (unlike before(:each)/before(:all)).

Examples

forbid(ApplicationRecord).from receiving(anything)

# a violation since User inherits from ApplicationRecord
allow(User).to receive(:find_by).and_return(...)

# also a violation
expect(User).to receive(:find_by)

# also a violation
allow(User).to receive_messages(find_by: ..., first: ...)

# also a violation
allow(User).to receive_message_chain(:find_by, :destroy)

# not a violation because the target is an instance
allow(User.first).to receive(:save)

# not a violation since this `allow` targets instances
allow_any_instance_of(User).to receive(:destroy)

# not a violation, `.and_call_original` is used
allow(User).to receive(:find_by).and_call_original
expect(User).to have_received(:find_by)
forbid_any_instance_of(ApplicationRecord).from receiving(anything)

# violation
allow_any_instance_of(User).to receive(:save)
expect_any_instance_of(User).to receive(:save)

# also a violation
allow(User.first).to receive(:save)

# also a violation
allow_any_instance_of(User).to receive_messages(save: ..., destroy: ...)

# also a violation
allow_any_instance_of(User).to receive_message_chain(:reload, :update)

# not a violation
allow(User).to receive(:find_by)

Motivation

Proliferation of AI coding tools has caused a surge in mocks across test suites, mocks as terrorizing as:

let(:address) { Hash.new }

before do
  allow(address).to receive(:fetch).with(:country).and_return('HR')
  allow(address).to receive(:fetch).with(:province).and_return('Grad Zagreb')
  allow(address).to receive(:fetch).with(:postal_code).and_return('10000')
  allow(address).to receive(:fetch).with(:city).and_return('Zagreb')
  allow(address).to receive(:fetch).with(:street).and_return('Trg Republike Hrvatske')
  allow(address).to receive(:fetch).with(:house_number).and_return('15')
end

When confronted with such slop on a pull request, the reviewer usually tries to gently point out a simpler way to construct hashes: for instance, the { key: value } syntax... But AI produces code faster than any single person can review it, and there's no sense in fighting slop on your own, hence Mockbidden:

forbid_any_instance_of(Hash).from receiving(anything)

But AI aside, there are probably things in any codebase which shouldn't be mocked, and that is the primary reason this gem exists. It's here to help guide developers on a project as to what is acceptable to mock, and what must always execute.

Obviously, there are many things which you could prevent from mocking, but that doesn't mean you should now spend hours configuring forbids for everything from A to Z (you are of course free to do whatever you like, even use forbid(anything).from receiving(anything)). To be pragmatic, forbid mocking only what is a real issue in your codebase. If your engineering team is on a level where you won't have to fear mocked hashes, then it's better to focus on other things.

Limitations

and_wrap_original always registers a violation, which might result in false positives when original code is called in the and_wrap_original block, e.g.:

allow(Foo).to receive(:bar).and_wrap_original do |m, *args|
  m.call(*args) # calls `Foo.bar`
end

Such cases might be handled in a future release.

Compatibility

This gem supports rspec-mocks >= v3.0.0. It has been implemented by extending private APIs and using their instance variables. While this implementation hasn't changed for years, any new release of rspec-mocks could break this gem.

The CI does its best to test latest patch versions of all minor versions since rspec-mocks v3.0.0 (see GitHub Actions workflow for more details), but be aware that future versions might not be compatible until official support is added.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/lovro-bikic/rspec-mockbidden. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the RSpec::Mockbidden project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.