Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,4 @@ Performance/ZipWithoutBlock: {Enabled: true}
# Enable our own pending cops.

RSpec/IncludeExamples: {Enabled: true}
RSpec/LeakyLocalVariable: {Enabled: true}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Fix a false positive for `RSpec/LeakyConstantDeclaration` when defining constants in explicit namespaces. ([@naveg])
- Add support for error matchers (`raise_exception` and `raise_error`) to `RSpec/Dialect`. ([@lovro-bikic])
- Don't register offenses for `RSpec/DescribedClass` within `Data.define` blocks. ([@lovro-bikic])
- Add new cop `RSpec/LeakyLocalVariable`. ([@lovro-bikic])

## 3.6.0 (2025-04-18)

Expand Down
6 changes: 6 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,12 @@ RSpec/LeakyConstantDeclaration:
StyleGuide: https://rspec.rubystyle.guide/#declare-constants
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyConstantDeclaration

RSpec/LeakyLocalVariable:
Description: Checks for outside local variables used in examples.
Enabled: pending
VersionAdded: "<<next>>"
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyLocalVariable

RSpec/LetBeforeExamples:
Description: Checks for `let` definitions that come after an example.
Enabled: true
Expand Down
1 change: 1 addition & 0 deletions docs/modules/ROOT/pages/cops.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
* xref:cops_rspec.adoc#rspeciteratedexpectation[RSpec/IteratedExpectation]
* xref:cops_rspec.adoc#rspecleadingsubject[RSpec/LeadingSubject]
* xref:cops_rspec.adoc#rspecleakyconstantdeclaration[RSpec/LeakyConstantDeclaration]
* xref:cops_rspec.adoc#rspecleakylocalvariable[RSpec/LeakyLocalVariable]
* xref:cops_rspec.adoc#rspecletbeforeexamples[RSpec/LetBeforeExamples]
* xref:cops_rspec.adoc#rspecletsetup[RSpec/LetSetup]
* xref:cops_rspec.adoc#rspecmatcharray[RSpec/MatchArray]
Expand Down
82 changes: 82 additions & 0 deletions docs/modules/ROOT/pages/cops_rspec.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -3391,6 +3391,88 @@ end
* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyConstantDeclaration
* https://rspec.info/features/3-12/rspec-mocks/mutating-constants
[#rspecleakylocalvariable]
== RSpec/LeakyLocalVariable
|===
| Enabled by default | Safe | Supports autocorrection | Version Added | Version Changed
| Pending
| Yes
| No
| <<next>>
| -
|===
Checks for outside local variables used in examples.
Local variables assigned outside an example but used within it act
as shared state, which can make tests non-deterministic.
[#examples-rspecleakylocalvariable]
=== Examples
[source,ruby]
----
# bad - outside variable used in a hook
user = create(:user)
before { user.update(admin: true) }
# good
let(:user) { create(:user) }
before { user.update(admin: true) }
# bad - outside variable used in an example
user = create(:user)
it 'is persisted' do
expect(user).to be_persisted
end
# good
let(:user) { create(:user) }
it 'is persisted' do
expect(user).to be_persisted
end
# also good - assigning the variable within the example
it 'is persisted' do
user = create(:user)
expect(user).to be_persisted
end
# bad - outside variable passed to included examples
attrs = ['foo', 'bar']
it_behaves_like 'some examples', attrs
# good
it_behaves_like 'some examples' do
let(:attrs) { ['foo', 'bar'] }
end
# good - when variable is used only as example description
attribute = 'foo'
it "#{attribute} is persisted" do
expectations
end
# good - when variable is used only to include other examples
examples = foo ? 'some examples' : 'other examples'
it_behaves_like examples, another_argument
----
[#references-rspecleakylocalvariable]
=== References
* https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/LeakyLocalVariable
[#rspecletbeforeexamples]
== RSpec/LetBeforeExamples
Expand Down
133 changes: 133 additions & 0 deletions lib/rubocop/cop/rspec/leaky_local_variable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# frozen_string_literal: true

module RuboCop
module Cop
module RSpec
# Checks for outside local variables used in examples.
#
# Local variables assigned outside an example but used within it act
# as shared state, which can make tests non-deterministic.
#
# @example
# # bad - outside variable used in a hook
# user = create(:user)
#
# before { user.update(admin: true) }
#
# # good
# let(:user) { create(:user) }
#
# before { user.update(admin: true) }
#
# # bad - outside variable used in an example
# user = create(:user)
#
# it 'is persisted' do
# expect(user).to be_persisted
# end
#
# # good
# let(:user) { create(:user) }
#
# it 'is persisted' do
# expect(user).to be_persisted
# end
#
# # also good - assigning the variable within the example
# it 'is persisted' do
# user = create(:user)
#
# expect(user).to be_persisted
# end
#
# # bad - outside variable passed to included examples
# attrs = ['foo', 'bar']
#
# it_behaves_like 'some examples', attrs
#
# # good
# it_behaves_like 'some examples' do
# let(:attrs) { ['foo', 'bar'] }
# end
#
# # good - when variable is used only as example description
# attribute = 'foo'
#
# it "#{attribute} is persisted" do
# expectations
# end
#
# # good - when variable is used only to include other examples
# examples = foo ? 'some examples' : 'other examples'
#
# it_behaves_like examples, another_argument
#
class LeakyLocalVariable < Base
MSG = 'Use `let` instead of a local variable which can leak ' \
'between examples.'

# @!method example_method?(node)
def_node_matcher :example_method?, <<~PATTERN
(send nil? #Examples.all _)
PATTERN

# @!method includes_method?(node)
def_node_matcher :includes_method?, <<~PATTERN
(send nil? #Includes.all ...)
PATTERN

def self.joining_forces
VariableForce
end

def after_leaving_scope(scope, _variable_table)
scope.variables.each_value { |variable| check_references(variable) }
end

private

def check_references(variable)
variable.assignments.each do |assignment|
next if part_of_example_scope?(assignment.node)

assignment.references.each do |reference|
next unless part_of_example_scope?(reference)
next if allowed_reference?(reference)

add_offense(assignment.node)
Copy link
Contributor Author

@lovro-bikic lovro-bikic Aug 3, 2025

Choose a reason for hiding this comment

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

Initially I added the offense to the reference node, but that created offense noise (if an offending variable is referenced many times, there will be many offenses). An offense on the assignment node reduces that noise, but it may not be clear which reference triggered the offense. Let me know what you think is the better option.

Here's an example:

   user = create(:user)
#  ^^^^^^^^^^^^^^^^^^^^ assignment offense (only one)

   before do
     user.update(admin: true)
#    ^^^^ reference offense
     user.flag!
#    ^^^^ reference offense (repeated for all references)
   end

end
end
end

def allowed_reference?(node)
node.each_ancestor.any? do |ancestor|
next true if example_method?(ancestor)
if includes_method?(ancestor)
next allowed_includes_arguments?(ancestor, node)
end

false
end
end

def allowed_includes_arguments?(node, argument)
node.arguments[1..].all? do |argument_node|
next true if argument_node.type?(:dstr, :dsym)

argument_node != argument &&
argument_node.each_descendant.none?(argument)
end
end

def part_of_example_scope?(node)
node.each_ancestor.any? { |ancestor| example_scope?(ancestor) }
end

def example_scope?(node)
subject?(node) || let?(node) || hook?(node) || example?(node) ||
include?(node)
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/rspec_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
require_relative 'rspec/iterated_expectation'
require_relative 'rspec/leading_subject'
require_relative 'rspec/leaky_constant_declaration'
require_relative 'rspec/leaky_local_variable'
require_relative 'rspec/let_before_examples'
require_relative 'rspec/let_setup'
require_relative 'rspec/match_array'
Expand Down
Loading