New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Rails/PersistenceCalledOutsideExample cop. #1016
Add Rails/PersistenceCalledOutsideExample cop. #1016
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good.
I've run it against https://github.com/pirj/real-world-rspec, let me quickly analyze the offences (as quickly as it's possible with ~9k offences).
spec/rubocop/cop/rspec/persistence_called_outside_example_spec.rb
Outdated
Show resolved
Hide resolved
spec/rubocop/cop/rspec/persistence_called_outside_example_spec.rb
Outdated
Show resolved
Hide resolved
False positives:
FactoryBot.define do
factory :project do
submitted_by { create(:user) }
end
describe Organisation, type: :model do
def setup_contribution_data
most_pr_user = create(:user)
RSpec.describe 'I18n' do
def test_translations
reference = locale_keys[locales.delete("en")]
describe Admin::LogEntriesController, type: :controller do
describe "POST create" do
def post_create(action: "create", logeable: create(:customer))
# real-world-rspec/administrate/spec/example_app/config/initializers/disable_xml_params.rb
if Rails::VERSION::MAJOR < 5
ActionDispatch::ParamsParser::DEFAULT_PARSERS.delete(Mime::XML)
end
FileUtils.touch "README"
touch path/"bin"/file
describe "users/grades" do
context "as a teacher" do
let_once(:course) { Course.create!(workflow_state: "available") }
yml_config[:user] = yml_config.delete :username
# real-world-rspec/spree/frontend/spec/spec_helper.rb
config.before do
create(:taxon, permalink: 'bestsellers')
end I've only scratched the surface, and have not found a single valid offence. Please don't get me wrong. I think this will make a very useful cop. But we'll have to relax it a bit to avoid false positives. WDYT of:
Also, https://github.com/pirj/real-world-rspec is a nice sandbox to test the cop, I suggest using it extensively to reduce false positives to the minimum while still keeping real offences detectable. |
Okay, so for "reducing its scope to those forbidden calls directly under example groups (and inside iterators directly under example groups)", right now I detect whether something is in an allowed block. Would your suggestion be to assume all blocks are allowed unless specifically forbidden, such as iterator blocks? So something like describe User do
before do
5.times do
User.create!
end
end
end but not describe User do
5.times do
User.create!
end
end Another alternative would be to have an |
Another alternative would be to make assumptions about receiver. Other than the initial # bad
describe User
5.times do # has a receiver
User.create!
end
end
# good
describe User
my_custom_before_block # no receiver
User.create!
end
end The downsides of this approach would be false negatives for ExampleGroups and SharedExamples but I think that is consistent with behavior precedented by |
Small correction, iterators and conditionals. RSpec.describe do
if pre_create
user = create(:user)
end
...
end |
Not sure if the detecting the receiver would help. def on_send(node)
return if example_group?(node.ancestors(:block).first) The above won't skip iterators and conditional blocks, but I guess it's not too hard to add that support. |
I think you meant |
Here is an example of what I got so far: # frozen_string_literal: true
module RuboCop
module Cop
module RSpec
class PersistenceCalledOutsideExample < Base
MSG = 'Persistence called outside of example.'
def on_send(node)
return unless persistent_call?(node)
return unless inside_describe_block?(node)
return if inside_method_definition?(node)
return if allowed_receiver?(node)
return if allowed_method?(node)
add_offense(node)
end
private
def persistent_call?(node)
forbidden_methods.include?(node.method_name.to_s)
end
def inside_describe_block?(node)
spec_group?(node.each_ancestor(:block).find(&method(:not_iterator_or_conditional_block?)))
end
def not_iterator_or_conditional_block?(node)
!%i(each).include?(node.method_name)
end
def allowed_receiver?(node)
return unless node.receiver.respond_to?(:const_name)
allowed_receivers.include?(node.receiver.const_name)
end
def allowed_receivers
cop_config['AllowedReceivers'] || []
end
def allowed_method?(node)
allowed_methods.include?(node.method_name.to_s)
end
def allowed_methods
cop_config['AllowedMethods'] || []
end
def forbidden_methods
cop_config['ForbiddenMethods'] || []
end
def inside_example_scope?(node)
node.each_ancestor(:block).any?(&method(:example_scope?))
end
def inside_method_definition?(node)
node.each_ancestor(:def).any?
end
end
end
end
end This seems to work but will have to get a long list of method names. I'll start with methods that accept blocks on |
return if example_group?(node.ancestors(:block).first)
Exactly.
There's an indefinite list actually, and matching their combinations with
we actually have knowledge about that. A pull request that makes RSpec DSL alias configuration configurable on RuboCop RSpec side is about to be merged. I believe you can already rely on that. See #956 for more info. |
I would probably call this cop |
I'm temporarily changing this PR to a draft while I make these change. I'll ping you when I'm ready for re-review. |
a9397a9
to
2ea5133
Compare
2ea5133
to
e10c878
Compare
@pirj I've made the following changes:
With the following - let_once
- let_it_be
- subject_once
- fab!
- before_all
- given
- given!
- background
- scenario
- where Trying to restrict calls based on receiver didn't seem necessary after these changes since there were no more false positives and seems like doing so may cause false negatives, for example: I kept the default |
Sorry, it's taking me longer than usual to review. Hope to get to it soon. |
lib/rubocop/cop/rspec/rails/persistence_called_outside_example.rb
Outdated
Show resolved
Hide resolved
Another attempt to digest and classify
RSpec.feature "Quantity Promotions", js: true do
background do
create(:store) let_it_be(:project) { create(:project, :repository) } before_all do
design1.update!(relative_position: 2) fab!(:badge) { Fabricate(:badge, name: 'Minion') } let_once(:course) { Course.create!(workflow_state: "available") } given(:variant) { create :variant }
given(:action) do
Spree::Promotion::Actions::CreateQuantityAdjustments.create(
allowed_receivers.include?(node.receiver.const_name) That's basically all of it. In general, I still think that:
is sufficient to detect creation of records before test suite initialization. I can't think of a situation where there's an attempt to delete/update/touch/increment records that we should care of. Do you?
|
Sorry, I've missed your comment about the recent changes you've made.
Agree.
Checking again 👍 |
No false positives. But no real offences either :D This cop is ~90 LoC. It's a maintenance cost. I have little doubt that it's perfect, most probably bugs of some kind will appear. I would rather prefer to extend its functionality to cover more cases than to reduce its functionality to avoid false positives. Method call detection except Customization option is nice, but with #956 coming up it would better use the new approach, that would also simplify the cop. I'd love to hear what @bquorning and @Darhazer think. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have mixed feelings about cops that check method names against a list of “disallowed methods”. Since we can only do static analysis, we rarely know if the receiver is an ActiveRecord class or instance, or whether I’m using the Factory or Builder patterns and my objects implement #create
or #build
without persisting.
I wonder if this entire class of problems (AR related ones, not mutation of global state in general unfortunately) could be solved by disallowing database access during spec setup? Or having separate rw and read-only connections, and only allowing read-only during spec setup? (I guess this is more a question for @pirj and rspec-rails)
class PostsTest < ApplicationTestCase
author = Author.create!(...)
test "..." do
# ...
end
end and since establishing the connection (and with Rails 6 connections can differ per-model), it gets outside of the responsibility of RSpec Rails. Since establishing the connection happens somewhere deep in Rails internals, and is used to e.g. get the list of the attributes, and this happens lazily, I can't think of a way to intentionnally breaking it for a So handling this nasty case in static analysis, even under the risk of false positives, is the only option I can think of. |
So in terms of things we want to change:
Reduce methods to: I included Are we in agreement that detecting the receiver is not needed or should I put in some work on restricting receiver also, for example, only flagging create_list if we know it is from FactoryBot?
Is there anything else? |
I don't really think this option is necessary at all. We're detecting calls made outside of the suite context, and I think they'd better go to spec/support or spec_helper.rb in any case. The example I've found: given(:action) do
Spree::Promotion::Actions::CreateQuantityAdjustments.create( is not yet detected as being in the example context because we lack the detection of From my perspective, 1 & 3, plus get rid of Do you feel this is a reasonable approach? I might miss something. |
Hey @pirj I've done 1 & 3 as well as removing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks great!
Thanks a lot for this incredible effort!
@Darhazer @bquorning WDYT?
Rails/PersistenceCalledOutsideExample: | ||
Description: Checks for persistence calls outside example blocks. | ||
Enabled: true | ||
VersionAdded: '1.43' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1.44
return if inside_example_scope?(node) || | ||
inside_method_definition?(node) || | ||
inside_proc_or_lambda?(node) || |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe extract those three into lazily_executed?
(pretty sure a better for such a method name exists) to appease CodeClimate?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I’d love to hear @Darhazer‘s opinion too.
inside_proc_or_lambda?(node) || | ||
allowed_method?(node) | ||
return unless inside_describe_block?(node) | ||
return unless persistent_call?(node) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven’t done benchmarks, but it looks like changing the order of these early return
s would be good for performance. I think persistent_call?
and allowed_method?
are significantly faster than traversing node ancestors. How about e.g.
def on_send(node)
return if allowed_method?
return unless persistent_call?
return unless inside_describe_block?
return if lazily_executed? # as per @pirj's suggestion
add_offense(node)
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Speaking on the performance, it might be better to start from an example group, and check recursively block/send nodes, breaking on valid scopes, instead of triggering the code on each send node.
|
||
Rails/PersistenceCalledOutsideExample: | ||
Description: Checks for persistence calls outside example blocks. | ||
Enabled: true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we need to add Safe: false
as well, as it would produce false positives
inside_proc_or_lambda?(node) || | ||
allowed_method?(node) | ||
return unless inside_describe_block?(node) | ||
return unless persistent_call?(node) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Speaking on the performance, it might be better to start from an example group, and check recursively block/send nodes, breaking on valid scopes, instead of triggering the code on each send node.
end | ||
|
||
def allowed_methods | ||
cop_config['AllowedMethods'] || [] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't get why we need both AllowedMethods and ForbiddenMethods
I have mixed feelings on the cop as well. It's a real problem to solve, but it's hard to do in a static analysis, especially with a single-file analysis. Such a cop is bound to produce false positives. The problem would be better handled at run time. |
I can only think of
I must confess, it hit my by surprise that such usage is not equivalent to |
The Rails department was extracted to rubocop-rspec_rails. It would be great if you could create this PR again there. |
Prevents persistence calls from being called outside example or hook. https://github.com/rubocop-hq/rubocop-rspec/issues/991
Before submitting the PR make sure the following are checked:
master
(if not - rebase it).CHANGELOG.md
if the new code introduces user-observable changes.bundle exec rake
) passes (be sure to run this locally, since it may produce updated documentation that you will need to commit).If you have created a new cop:
config/default.yml
.VersionAdded
indefault/config.yml
to the next minor version.