Skip to content
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 new RSpec/PreferredSharedExampleMethods cop to enforce consistency when defining shared examples #2065

Closed
wants to merge 2 commits into from

Conversation

dvandersluis
Copy link
Member

@dvandersluis dvandersluis commented Mar 25, 2025

Allows RSpec/SharedExamples to be configured with preferred methods for defining shared examples and context, so that consistency can be enforced.

Follows rubocop/rubocop#14033, and reimplementation of #2062.


Before submitting the PR make sure the following are checked:

  • Feature branch is up-to-date with master (if not - rebase it).
  • Squashed related commits together.
  • Added tests.
  • Updated documentation.
  • Added an entry to the CHANGELOG.md if the new code introduces user-observable changes.
  • The build (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:

  • Added the new cop to config/default.yml.
  • The cop is configured as Enabled: pending in config/default.yml.
  • The cop is configured as Enabled: true in .rubocop.yml.
  • The cop documents examples of good and bad code.
  • The tests assert both that bad code is reported and that good code is not reported.
  • Set VersionAdded: "<<next>>" in default/config.yml.

If you have modified an existing cop's configuration options:

  • Set VersionChanged: "<<next>>" in config/default.yml.

@dvandersluis dvandersluis requested a review from a team as a code owner March 25, 2025 20:11
@dvandersluis dvandersluis requested a review from pirj March 25, 2025 20:11
.rubocop.yml Outdated
Comment on lines 124 to 127
PreferredExamplesMethod: shared_examples
PreferredContextMethod: shared_context
PreferredIncludeExamplesMethod: include_examples
PreferredIncludeContextMethod: include_context
Copy link
Member Author

@dvandersluis dvandersluis Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I chose the most commonly used methods in the codebase:

  • shared_examples was used 10 times and shared_examples_for was used 4 times.
  • include_examples was used 42 times and it_behaves_like was used twice.

Copy link
Contributor

@lovro-bikic lovro-bikic Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just fyi, include_examples and it_behaves_like don't do the same thing (unlike shared_examples and shared_examples_for which are synonyms).

Most projects I worked on typically used both, include_examples for example can't be used if you want to do multiple includes which override each other's setups:

RSpec.describe do
  shared_examples 'foo' do |var|
    subject { var }

    it { is_expected.to eq(var) }
  end

  include_examples 'foo', 1 # fails because the second include sets subject to 2
  include_examples 'foo', 2
end

so you need to use it_behaves_like instead to wrap each include in a context.

Copy link
Member Author

@dvandersluis dvandersluis Mar 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this, I did not realize they behaved differently. So a few thoughts:

  1. The easiest solution is probably just to leave PreferredIncludeExamplesMethod unset (which I should probably do here, because I didn't realize the distinction), and allow users to set it if they exclusively want to use one or the other.
  2. It is also possible to do what I did in the spec for this cop and define a top level context within the shared examples, in which case include_examples still works since the examples have their own contexts. The changed it_behaves_like examples still work for the same reason, but it might be worthwhile to undo that change.
  3. The Language hash doesn't distinguish between the two which is what this cop is based on. I'm not sure if it should, but that is outside the scope of this PR at the least.
  4. Given that include_examples doesn't create a context, we should probably have a cop that looks for shared examples that set subject or let that then are called with include_examples multiple times in the same context. Again outside of the scope of this PR, but would be good to prevent a footgun.
  5. The other solution here would be to remove the PreferredIncludeExamplesMethod configuration, or even not consider includes at all. I don't think it's a huge risk to keep it given that it defaults to unset (ie. all methods are acceptable).
  6. We could theoretically treat include_examples differently, but again because the different Includes are not distinguished in the Language hash, the problem could still exist if it is aliased.
  7. At the very least we should document this distinction.

/cc @bquorning @pirj

@dvandersluis dvandersluis force-pushed the preferred-shared-example-methods branch from 495830d to 5205c8a Compare March 25, 2025 20:17
@dvandersluis dvandersluis force-pushed the preferred-shared-example-methods branch 2 times, most recently from f695914 to ed551f6 Compare March 25, 2025 20:57
Copy link
Collaborator

@bquorning bquorning left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pirj, what do you think?

@pirj
Copy link
Member

pirj commented Mar 26, 2025

As @lovro-bikic mentions, include_examples and it_behaves_like are different. Let's split the cop (again!).
One cop will enforce which alias to use.

The other - whether to include examples or include them into nested context, include_examples vs it_behaves_like.

There are use cases for include_examples, but in most cases it's a footgun. I suggest no configurable value, just the cop to flag include_examples as an offence. Those who understand the difference, and who deliberately want it, can disable the cop.

a cop that looks for shared examples that set subject or let that then are called with

Methods as well. Also, before/after hooks can run multiple times if defined in a shared group and included multiple times. Too much of an effort to detect those side effects, and I'm uncertain if this can be done reliably. I can't recall major benefits of using include_examples to justify its usage in specs, and the effort to create a cop to ensure its safe usage.

There may be another cop that would detect shared contexts that contain examples, and yet another, that will detect no examples in a shared example group. We may have an issue to track this idea already.

I've grepped real-world-rspec, and usage stats are like (rough $ rg --no-ignore --hidden --glob *.rb --no-filename shared_examples_for | wc -l, seems not to count blank lines):

aliases:
shared_context - 636
shared_examples - 6401
shared_examples_for - 1295

include_examples - 4450
include_context (does the same as above!) - 2628

aliases:
it_behaves_like - 24441
it_should_behave_like - 562

it_should_behave_like will be removed in RSpec 4, other aliases will remain.

My proposal:

  1. For the cop that enforces a preferred alias to use, not consider include_examples and it_behaves_like aliases
  2. File an issue to add a cop to flag include_examples
  3. File (unless it's there already) an issue to add a cop for enforcing semantic use of shared_context vs shared_examples

@dvandersluis dvandersluis force-pushed the preferred-shared-example-methods branch from ed551f6 to f315ea9 Compare March 26, 2025 14:20
@dvandersluis
Copy link
Member Author

@pirj thank you for the feedback!

I have updated this PR to only apply to defining shared examples.

There are use cases for include_examples, but in most cases it's a footgun. I suggest no configurable value, just the cop to flag include_examples as an offence. Those who understand the difference, and who deliberately want it, can disable the cop.

I'll work on this next.

@dvandersluis dvandersluis changed the title Add new RSpec/PreferredSharedExampleMethods cop to enforce consistency when defining and including shared examples Add new RSpec/PreferredSharedExampleMethods cop to enforce consistency when defining shared examples Mar 26, 2025
@dvandersluis dvandersluis force-pushed the preferred-shared-example-methods branch 3 times, most recently from a5552f5 to cc777b3 Compare March 26, 2025 14:41
@dvandersluis
Copy link
Member Author

BTW I just realized that it_behaves_like vs it_should_behave_like is already covered by RSpec/ItBehavesLike 😄

@pirj
Copy link
Member

pirj commented Mar 26, 2025

I am sorry to have missed that. We seem to be in the spot where we can’t remember all the cops we already have 😅

@dvandersluis dvandersluis force-pushed the preferred-shared-example-methods branch from cc777b3 to eacb4d6 Compare March 27, 2025 15:40
@dvandersluis
Copy link
Member Author

@pirj @bquorning anything else needed for this one?

@pirj
Copy link
Member

pirj commented Mar 27, 2025

I was always curious, and this question reveals each time when I think about directory-local .rubocop.yml

which could come handy in a case when there’s an alias defined

eg ‘shared_context’ is preferred in specs/, but shared_state in features/

How does it work with multi-threaded RuboCop, that nechanism that parallelizes execution? There should be separate ConfigLoaders (or whatever they are called).

How does rubocop-rspec work in this case? We do have a global Language module that keeps a reference to the config?

#racecondition

Can’t this be a source of flaky subtle bugs?

@pirj
Copy link
Member

pirj commented Mar 27, 2025

The question above is totally unrelated to the PR’s code.
It just made me remember this again

@dvandersluis dvandersluis force-pushed the preferred-shared-example-methods branch from eacb4d6 to f28054f Compare March 28, 2025 04:35
@dvandersluis
Copy link
Member Author

@pirj I've addressed your requests now.

@dvandersluis
Copy link
Member Author

dvandersluis commented Mar 28, 2025

which could come handy in a case when there’s an alias defined

eg ‘shared_context’ is preferred in specs/, but shared_state in features/

I tried this out and it should work how you expect. You can create .rubocop.yml files in subdirectories, and they do get loaded (and can inherit from the main one too).

How does it work with multi-threaded RuboCop, that nechanism that parallelizes execution? There should be separate ConfigLoaders (or whatever they are called).

There's a lot of layers to investigate here, but ultimately what happens in short is that each inspected file mobilizes a team of cops, during which a config object is created for that file. ConfigStore#for_file eventually calls ConfigFinder.find_project_dotfile, which calls FileFinder.find_file_upwards which searches from the file directory upwards until a configuration file is found (and uses the first one found).

So each file gets the correct config (there's some caching involved as well) for the directory it's in.

We do have a global Language module that keeps a reference to the config?

Language has a class level attr_accessor config, but it is set on_new_investigation which means that each cop will reset the Language data each time it is run. A cop can be run one or more times, per file (depending on if there are autocorrections that cause another iteration), so this reset can happen a lot of times. As far as I can tell, there is no further caching happening (each time a Language method is called it does new hash lookup).

Therefore, per-directory Language should work properly as well.

Can’t this be a source of flaky subtle bugs?

I don't think so because even with parallelization, each file runs in a single thread.

Description: Checks for consistent method selection for shared examples.
Enabled: pending
VersionAdded: "<<next>>"
PreferredExamplesMethod: ~
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussion starter. Should we provide a setting here? Otherwise, the cop is a no-op, no?

Rarely people jump and configure new cops. Unless they’ve implemented them themselves 😅

There’s evidence that one style prevails, but I’m not sufficiently confident that we should.

Why do people use the shared_examples_for? (We did as the second commit says). Does it read better in some cases?

Why do we implement this cop in the first place? Do we want to get rid of shared_examples_for? Or do we want to enforce use of aliases where due? Or prevent such usage?

Copy link
Member Author

@dvandersluis dvandersluis Mar 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's useful to enforce consistency, there are plenty of other examples of this across RuboCop of cops that are just meant to enforce one style or another when there isn't an actual difference between them. For instance, Style/CollectionMethods allows users to choose to enforce map vs. collect, etc.

I didn't specify actual defaults because I don't think there is one answer (but we could if we want...).

Why do we implement this cop in the first place?

In a codebase with many contributors, there is no particular reason (IMO) to use one alias over the other so it becomes inconsistent. I noticed this in the main rubocop repo and wanted to fix it as an InternalAffairs cop, and it was suggested to add a RuboCop::RSpec cop instead 😆 In the meantime, the inconsistency was fixed directly in rubocop/rubocop#14034.

@@ -775,6 +775,14 @@ RSpec/PredicateMatcher:
StyleGuide: https://rspec.rubystyle.guide/#predicate-matchers
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/PredicateMatcher

RSpec/PreferredSharedExampleMethods:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a different note, I suddenly recalled we have RSpec/Dialect cop 🙈😭

I sincerely apologise if that other cop does what this one does with aliases, again, it slipped my mind as I’m less involved with the project lately.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wow! I didn't know about it either. The tests for it are pretty sparse and the documentation is a bit confusing as to how to configure, but it does seem to do what I was looking to do.

RSpec/Dialect:
  Enabled: true
  PreferredMethods:
    shared_examples_for: shared_examples

With this in mind, we can probably close this PR.

@pirj
Copy link
Member

pirj commented Mar 28, 2025

Thank you for the in-depth explanation, @dvandersluis !

How does context switch happen, how do we load multiple cores? Do we have a pool of rubocop processes, each with its own set of class-level attributes? Or can a thread context switch happen before the cop finished the investigation, and some other cop would overwrite the cached language DSL config?
How does this isolation work?
Can we guarantee that if a cop finishes with this config being untouched?

@dvandersluis
Copy link
Member Author

@pirj parallelization is done by the parallel gem, with default options (although it can be tuned by ENV vars).

@dvandersluis
Copy link
Member Author

I'm going to close this PR given RSpec/Dialect already covers this. If there's anything you want me to extract from this (for instance, enabling RSpec/Dialect and replacing shared_examples_for, please let me know!)

@dvandersluis dvandersluis deleted the preferred-shared-example-methods branch March 28, 2025 05:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants