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.