When to Replace Instance Variables with let and let! in RSpec
Replace hidden instance-variable dependencies in RSpec before hooks with let and let!. Make setup explicit, lazy where possible, and isolated so failures point to the example.
12 Sept 2026, 04:02 UTC

Instance variables assigned inside RSpec before hooks create hidden dependencies. When an example fails, you must trace hook execution order to discover where @user was assigned and whether another example changed it. Replacing example-local instance variables with let and let! makes setup explicit, lazy where possible, and isolated, so failures point to the example rather than to leaked state.
Desired outcome
Each example's setup should be readable at the point of use. A value that only one example needs should be defined by that example's group and evaluated only when referenced. A value whose side effect must exist before the example body runs should be forced eagerly. The suite should not depend on execution order, and a missing or renamed helper should produce a clear failure in the example that references it.
Prerequisites
- An RSpec example group with the expectation framework loaded.
- Factories or fixtures available if your examples use them. The examples below assume a factory helper such as
create(:user); substitute your project's equivalent. - A runnable spec command such as
bundle exec rspec. - Confirmation that your RSpec version supports
letandlet!. Both have been part of RSpec for many releases, but verify in your project's version before relying on edge-case behavior.
Focused procedure
Replace instance variables used by a single example
When a before hook assigns an instance variable that only the current example uses, define a let helper instead. Reference the helper by name inside the example or inside a before(:example) hook.
# Before
RSpec.describe User, type: :model do
before { @user = create(:user) }
it "has a valid factory" do
expect(@user).to be_valid
end
end
# After
RSpec.describe User, type: :model do
let(:user) { create(:user) }
it "has a valid factory" do
expect(user).to be_valid
end
end
let is memoized per example. The first call to user inside an example evaluates the block and caches the result for that example only. Other examples receive a fresh evaluation. Do not use let to share state across examples.
Use let! only when the side effect must exist before the example body
let is lazy: if an example never references user, the block never runs. When the example asserts a database count, an enqueued job, or another side effect that must be present before the example body executes, use let! to force evaluation before the example runs.
RSpec.describe User, type: :model do
let!(:user) { create(:user) }
it "persists a user record" do
expect(User.count).to eq(1)
end
end
Keep expensive setup lazy whenever possible. A let! block runs before every example in the group, even examples that do not reference the helper. If most examples do not need the eager side effect, the suite pays that cost repeatedly.
Expected checks
- Run the focused example using its real file and line number to confirm the refactor passes in isolation:
Replacebundle exec rspec spec/models/user_spec.rb:42spec/models/user_spec.rband42with your actual path and line number. The example should pass without the old instance variable. - Run the example by name to verify a renamed or moved helper is still referenced correctly:
A missing helper should fail inside the example with a clearbundle exec rspec spec/models/user_spec.rb --example 'has a valid factory'NameErrorrather than silently passing. - Temporarily change the
letbody to raise or return a different value. Confirm that only examples referencing the helper fail, then revert the change. This proves the helper is example-local and not leaking state. - Run the full file or your normal suite command to check for order-dependent leakage, especially after replacing instance variables or adding
let!:
If failures appear only when the full file runs, an example is still relying on state from another example.bundle exec rspec spec/models/user_spec.rb
Limitations and recovery options
let is not available in context hooks
Do not call let helpers from before(:context) or after(:context). They are not available there, and the failure mode differs by RSpec version. If setup is needed at the context level, move it to before(:example), use let! with a transaction-safe cleanup strategy, or extract a plain helper method.
Database cleanup around let!
If let! creates database records, ensure your cleanup strategy—such as transactional fixtures or an equivalent truncation strategy—runs around each example. Without it, records from one example can leak into the next.
When instance variables remain appropriate
Values created in before(:context) may still be assigned to instance variables, because let cannot be used there. However, instance variables increase coupling and leakage risk across examples. Prefer before(:example) with let or let! unless the context-level setup is genuinely shared and read-only.
If the suite becomes slow
If examples become slow after converting before hooks to let!, convert unnecessary let! declarations back to let. Only keep let! for examples that assert side effects before the example body runs.
Practical verification
After refactoring, run the full file and compare the failure output to the pre-refactor run. A successful refactor produces the same pass/fail results, but failures now name the missing helper directly instead of showing an undefined instance variable. If a previously passing example fails with NameError: undefined local variable or method 'user', the example was relying on an instance variable that was not converted.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.