A RuboCop extension that detects
unreferenced RSpec let definitions.
It adds a single cop, RSpec/UnusedLet, which flags let (and optionally
let!) definitions whose name is never referenced within their scope. The cop
resolves shared_examples references precisely when it can see the shared
block — in the same file, or in a file listed in SharedExamplePaths — and
stays conservative otherwise, so that it avoids false positives that a naive
implementation would produce.
Add the gem to your Gemfile:
gem "rubocop-rspec-unused-let", require: falseThis gem builds on rubocop-rspec, so make sure that is available too.
Enable both plugins in your .rubocop.yml:
plugins:
- rubocop-rspec
- rubocop-rspec-unused-let# bad
RSpec.describe Foo do
let(:used) { 1 }
let(:unused) { 2 } # never referenced
it { expect(used).to eq(1) }
end
# good
RSpec.describe Foo do
let(:used) { 1 }
it { expect(used).to eq(1) }
endA let is considered used when its name appears as a bare method call in any
of these places:
- in an example of the group that defines it, or of any group nested inside that one — but not in an ancestor's example
- in a helper body — a hook (
before/after/around),subject/subject!, anotherlet, or a plaindefmethod written at a group's level — of any of those groups or of an ancestor
Any of these may instead be a dynamic dispatch with a literal name: send,
public_send, __send__, method or respond_to?.
Because RuboCop analyzes one file at a time, a let can be consumed by a shared
example block defined in another file. An inclusion is in reach when the cop
can resolve it: the name is a literal, RSpec's scoping makes a definition of it
visible at that point, and the same holds for whatever that block includes in
turn. The cop is precise for those and conservative for the rest:
letdefinitions inside ashared_examples/shared_contextblock — including any nestedcontext/describewithin it — are never flagged, since the groups that include the block (possibly in other files) may reference them.- When the included block is in reach, only the
lets it actually references are treated as used; every otherletstays checked. - When it is not in reach, the cop cannot tell what it references, so it leaves
every
letvisible at that inclusion point alone. Sibling subtrees without such an inclusion are still checked. To resolve blocks defined in other files (e.g. underspec/support), list them inSharedExamplePaths(see below). - Whether a
letin the including group with the same name as one in the shared block is checked depends on the inclusion: an inline one (include_examples/include_context) makes it the override, so it counts as used when the block references the name; a nested one (it_behaves_like/it_should_behave_like) does not. The match is approximate; see Known limitations.
RSpec.shared_examples "uses a" do
it { expect(a).to eq(1) } # references `a`, and only `a`
end
RSpec.describe Foo do
let(:a) { 1 } # skipped: referenced by the shared block above
let(:b) { 2 } # flagged: the shared block never references it
it_behaves_like "uses a"
endFor an inclusion the cop cannot resolve, it falls back to silencing every
visible let:
RSpec.describe Foo do
let(:a) { 1 } # skipped: visible at the inclusion below
context "with shared" do
let(:b) { 2 } # skipped: same
it_behaves_like "an external thing" # in another file, not pre-loaded
end
context "other" do
let(:c) { 3 } # checked: the shared block cannot see `c`
it { expect(c).to eq(3) }
end
endAn inline inclusion lets the including group override a name the shared block uses, where a nested one does not:
RSpec.shared_examples "uses size" do
let(:size) { 1 }
it { expect(size).to be_positive }
end
RSpec.describe Foo do
include_examples "uses size"
let(:size) { 2 } # skipped: overrides the `size` the shared block uses
end
RSpec.describe Bar do
it_behaves_like "uses size"
let(:size) { 2 } # flagged: the nested group uses its own `size`
endShared examples usually live under spec/support and are included from many
spec files. List those files in SharedExamplePaths (paths or globs) and the
cop pre-loads them, so an inclusion of a block defined there is resolved with
the same precision as an in-file one, instead of the conservative fallback
above.
# .rubocop.yml
RSpec/UnusedLet:
SharedExamplePaths:
- "spec/support/**/*.rb"Paths resolve relative to the .rubocop.yml that sets them (as Include and
Exclude do). A listed file that is missing or cannot be parsed is skipped, and
when a name is defined both in a pre-loaded file and in the spec itself, the
in-file definition wins (mirroring RSpec's load order).
Only a pre-loaded file's top-level blocks are resolved.
The cop can remove flagged let definitions automatically, but the
correction is marked unsafe because a let! block may exist for its
side effects. Run rubocop --autocorrect-all (or -A) to apply the
corrections, and review the diff before committing.
# before -A
RSpec.describe Foo do
let(:used) { 1 }
let(:unused) { 2 }
it { expect(used).to eq(1) }
end
# after -A
RSpec.describe Foo do
let(:used) { 1 }
it { expect(used).to eq(1) }
endRSpec/UnusedLet:
# Whether to also check `let!`. On by default. Since `let!` is sometimes used
# purely for its side effects (e.g. `let!(:user) { create(:user) }`), set this
# to `false` to opt out.
CheckLetBang: true
# Whether to check helper specs. Off by default. A helper spec (rspec-rails
# `type: :helper`, or a spec file under `spec/helpers`) auto-includes the
# described module into the example group, so its externally defined methods
# may reference any `let` in scope — invisibly to single-file analysis. Set
# this to `true` to check them anyway, accepting the risk of false positives.
CheckHelperSpecs: false
# Files defining shared examples/contexts, as paths or globs. Empty by
# default. Listing them lets the cop resolve inclusions of blocks defined in
# other files precisely instead of silencing every visible `let` — see
# "Resolving shared examples defined in other files" above.
SharedExamplePaths: []Some gems ship a shared context that dereferences let names dynamically
(e.g. via eval), so a single-file static analysis cannot see the
references. When the cop recognizes such a gem by the type: metadata on
an example group (or one of its ancestors), it treats the affected let
names as used automatically.
Currently supported:
- rspec-validator_spec_helper
— groups tagged with
type: :validatormay definelet(:value),let(:attribute_names),let(:options)(and the helper's other overridable lets) without being flagged.
RSpec.describe JsonFormatValidator, type: :validator do
let(:value) { "String" } # not flagged
it { is_expected.to be_invalid }
endHelper specs (rspec-rails type: :helper groups, or spec files under
spec/helpers) auto-include the described module into the example group.
Its methods live in another file and may reference any let in scope, so a
single-file static analysis cannot see those references. To avoid false
positives, such groups are skipped by default. Set CheckHelperSpecs: true
to check them anyway.
RSpec.describe MyHelper, type: :helper do
let(:current_user) { User.new } # not flagged (may be used by MyHelper's methods)
it { expect(helper.greeting).to eq("Hi") }
end- Analysis is limited to the file under inspection plus the files listed in
SharedExamplePaths. Aletreached only from outside that range (a module mixed into the example group) or through a name that is not statically known (send(attribute)) can be a false positive. The cases the sections above cover are deliberately left unflagged instead. - The override an inline inclusion allows is matched approximately: it can flag
a
letthe shared block does use through a further inclusion of its own, and leave one alone that RSpec would in fact render dead — aletwritten before the inclusion, say.
After checking out the repo, run bin/setup to install dependencies. Then run
rake spec to run the tests and rake rubocop to lint the gem. rake runs
both.
Bug reports and pull requests are welcome on GitHub at https://github.com/tk0miya/rubocop-rspec-unused-let.
The gem is available as open source under the terms of the MIT License.