Draft: Add RSpec/LetItBeImmutable RuboCop cop
What does this MR do and why?
Adds the RSpec/LetItBeImmutable RuboCop cop, which enforces that every let_it_be
declaration carries an explicit mutation modifier.
The motivation is that the testing guide already recommends treating let_it_be objects
as immutable,
but with ~73k usages across the repo, fewer than 1% currently use freeze: true. Silent
mutation of shared state is a common source of flaky and order-dependent tests. Making the
intent explicit at the declaration site - rather than relying on convention - surfaces
these issues earlier and encourages contributors to reach for the right tool.
Accepted modifiers (any one is sufficient):
| Modifier | Behavior |
|---|---|
freeze: true |
Freezes the Ruby object; mutations raise FrozenError. Default-safe choice. |
reload: true |
Calls object.reload after each example. Equivalent to let_it_be_with_reload. |
refind: true |
Calls ActiveRecord::Base.find after each example. Equivalent to let_it_be_with_refind. |
The named helpers let_it_be_with_reload and let_it_be_with_refind are also accepted
without an offense, since they are already self-documenting.
Rollout strategy:
The cop is enabled globally with error severity. A TODO file
(.rubocop_todo/rspec/let_it_be_immutable.yml) covers the ~11.5k existing offending
files, matching how other large cops (RSpec/FeatureCategory, RSpec/NamedSubject, etc.)
were introduced. New files must comply immediately; existing files are cleaned up gradually
in focused follow-up MRs, starting with spec/models/.
This approach was chosen over a directory-scoped Include allowlist because it also
protects new code added to not-yet-cleaned-up directories.
Open questions for maintainers
This MR is intentionally opened as a discussion starter. Happy to iterate on any of the following before merging:
- Rollout strategy - is a global cop + TODO file acceptable, or would you
prefer an
Include-based allowlist that grows directory by directory? The TODO file is ~11.5k lines, about 3x the next largest (RSpec/FeatureCategory), so I'm totally open to alternatives. freeze: falsebehavior - currently flagged (no offense is raised only fortruevalues).- Non-AR objects - the cop applies to all
let_it_becalls, not just ActiveRecord-backed ones.
References
Screenshots or screen recordings
Not applicable - tooling change only.
How to set up and validate locally
-
Run the cop spec:
bundle exec rspec spec/rubocop/cop/rspec/let_it_be_immutable_spec.rbExpected: 10 examples, 0 failures.
-
Check a file that should have offenses (with TODO suppressed):
REVEAL_RUBOCOP_TODO=1 bundle exec rubocop --only RSpec/LetItBeImmutable spec/models/user_spec.rb -
Check a file that already uses
freeze: true(should be clean):bundle exec rubocop --only RSpec/LetItBeImmutable spec/support_specs/helpers/ci/job_helpers_spec.rb
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.