RSpec: 5 rules for using let effectively
letcan enhance readability when used sparingly (1, 2, or maybe 3 declarations) in any given example group, but that can quickly degrade with overuse.
— RSpec Official Documentation
Motivation
I’ve been using Rspec since 2012 and in all this time I’ve never had a really clear picture of how to best use let. I don’t have this issue with any other aspect of Rspec.
Probably our most common Rubocop violation where I work is MultipleMemoizedHelpers (too many let calls). In previous consulting work, I’ve seen this in many other codebases as well.
What to do about it? On one side, you have Thoughtbot (the creators of FactoryBot) and other prominent members of the community arguing that you should never use let, because of the tangled messes they often see with let overuse in large codebases (eliminating let usage isn’t a practical option for us, given our multiple large legacy Rails applications and many teams). On the other side, you have the well regarded Better Specs site recommending let and of course the Rspec docs themselves, but their examples only cover simple cases. Then in the middle, there’s the quote at the top of this page: a somewhat cryptic comment tucked away in the Rspec code documentation, cautioning to use let sparingly.
But what does it mean to use let sparingly? What is the right strategy for reducing the number of MultipleMemoizedHelpers violations? Why should I care? After a lot of research, focusing primarily (but not solely) on advice from Rspec maintainers that I found in various corners of the internet, I formulated the 5 rules below to answer these questions.
Much of the advice in these rules is really about good habits in general with writing tests, through the lens of using let effectively. let is a tool. It’s up to you how to use it.
ℹ️ Note there’s a Claude Code skill waiting for you at the end of the post, so Claude will know how to use let effectively too.
Rule 1: DAMP over DRY – write inline first
Start with inline setup. Extract to let only after 3+ uses.
Bad Example
# Starting with let before writing any tests
describe UserService do
let(:user_attributes) { { name: "Test", email: "test@example.com" } }
let(:user) { create(:user, user_attributes) }
# Then writing first test...
end
Good Example
describe UserService do
it "creates user with valid attributes" do
user = create(:user, name: "Test", email: "test@example.com")
expect(user).to be_valid
end
it "sends welcome email" do
user = create(:user, name: "Test", email: "test@example.com")
expect { UserService.welcome(user) }.to change { ActionMailer::Base.deliveries.count }
end
it "sets default preferences" do
user = create(:user, name: "Test", email: "test@example.com")
expect(user.preferences).to be_present
end
# NOW refactor to let after 3rd use:
# let(:user) { create(:user, name: "Test", email: "test@example.com") }
end
Rationale
For your production code, you should err on the side of the DRY principle
For the test code, you should favor DAMP (Descriptive and Meaningful Phrases) over DRY
— DRY vs DAMP in Unit TestsIn general, it is best to start with doing everything directly in your
itblocks even if it is duplication and then refactor your tests after you have them working to be a little more DRY. However, keep in mind that duplication in test suites is NOT frowned upon, in fact it is preferred if it provides easier understanding and reading of a test.
— RSpec Style Guide
letis a refactoring tool, not a starting point—don't reach for it by default- You can't identify genuine duplication until you've written multiple tests
- Inline setup keeps tests self-contained and easier to understand
- Only extract when: (1) used 3+ times and (2) has the same value across ALL tests in the given context and child contexts
Rule 2: 1-3 let calls per context
Aim for 1-3 let calls per context. Don't exceed 5 (the Rubocop default maximum for MultipleMemoizedHelpers).
Bad Example
describe "Payments" do
let(:organization) { create(:organization) }
let(:user) { create(:person, organization: organization) }
let(:subscription) { create(:subscription, organization: organization) }
let(:invoice) { create(:invoice, subscription: subscription) }
let(:valid_params) { { amount: 1000 } }
describe "POST /payments" do
before { sign_in(user) }
# Only uses user and valid_params (2 of the 6 lets)
it "creates payment" do
post "/payments", params: valid_params
expect(response).to be_successful
end
end
describe "GET /invoices/:id" do
before { sign_in(user) }
# Only uses user and invoice (2 of the 6 lets)
it "displays invoice" do
get "/invoices/#{invoice.id}"
expect(response).to be_successful
end
end
# [... more similar tests ...]
end
Good Example
RSpec.describe "Payments" do
# Only user is used in every child context
let(:user) { create(:person) }
describe "POST /payments" do
let(:valid_params) { { amount: 1000 } }
before { sign_in(user) }
it "creates payment" do
post "/payments", params: valid_params
expect(response).to be_successful
end
end
describe "GET /invoices/:id" do
let(:subscription) { create(:subscription, person: user) }
let(:invoice) { create(:invoice, subscription: subscription) }
before { sign_in(user) }
it "displays invoice" do
get "/invoices/#{invoice.id}"
expect(response).to be_successful
end
end
end
Rationale
letcan enhance readability when used sparingly (1, 2, or maybe 3 declarations) in any given example group, but that can quickly degrade with overuse.
— RSpec Official Documentation
- Hidden complexity:
letobscures test dependencies. Thoughtbot documented a test that "referenced 18 otherletstatements, inserted 23 database records and ran 25 queries" – all invisible when reading the test itself - Not obvious what's created: With many
lets, you must scan all definitions and trace through test logic to understand which data actually exists - Performance drift and tight coupling: Because
letmakes fixtures reusable, teams gradually add more setup to sharedletblocks, creating unintended setup dependencies between tests and degrading performance over time
Rule 3: Never override let in child contexts
If even one child context needs different data, don't define let in the parent
Bad Example
describe "organization scenarios" do
let(:organization) { create(:organization, plan: "free") }
context "with free plan" do
# Uses parent organization
end
context "with paid plan" do
# Different organization - confusing!
let(:organization) { create(:organization, plan: "paid") } # Override
end
# ... 100+ lines of other tests ...
context "with enterprise plan" do
# Oops, forgot to override - still using "free"
end
end
Good Example
describe "organization scenarios" do
context "with free plan" do
let(:organization) { create(:organization, plan: "free") }
# Tests here
end
context "with paid plan" do
let(:organization) { create(:organization, plan: "paid") }
# Tests here
end
context "with enterprise plan" do
let(:organization) { create(:organization, plan: "enterprise") }
# Tests here
end
end
Rationale
Use
letonly to extract an object in a context where you intentionally mean to state: This object should represent the exact same state/concept across ALL of the following specs.
— Aaron Kromer (RSpec core team), GitHub Discussion on RSpec Best Practices
- If you're changing a
letvalue multiple times in nested child contexts, it can lead to hard to debug issues, especially when contexts are far apart, have layers of nesting, or declarations are overridden - Makes tests hard to understand in isolation – you have to check parent contexts to know what data exists, often in more than one test setup location
- Can lead to defensive "fixture assumption" tests when you don't trust the test setup
Rule 4: Each context's lets should be used by all its tests
Only define let statements that are used by every test in that context.
Bad Example
describe "Payments" do
# All lets at top level, but different contexts use different subsets
let(:organization) { create(:organization) }
let(:user) { create(:person, organization: organization) }
let(:subscription) { create(:subscription, organization: organization) }
let(:payment_method) { create(:payment_method, organization: organization) }
let(:invoice) { create(:invoice, subscription: subscription) }
describe "POST /payments" do
before { sign_in(user) }
context "with valid params" do
# organization, subscription, payment_method, and invoice are all unused
it "creates payment" do
post "/payments", params: { amount: 1000 }
expect(response).to be_successful
end
end
context "with invalid params" do
# organization, subscription, payment_method, and invoice are all unused
it "returns error" do
post "/payments", params: { amount: -100 }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "PATCH /subscriptions/:id/payment_method" do
before { sign_in(user) }
# Only uses user, subscription, and payment_method
# organization and invoice are unused
it "updates the payment method" do
patch "/subscriptions/#{subscription.id}/payment_method",
params: { payment_method_id: payment_method.id }
expect(response).to be_successful
end
end
describe "GET /invoices/:id" do
before { sign_in(user) }
# Uses all the lets, but it's the only context that needs invoice
it "displays invoice details" do
get "/invoices/#{invoice.id}"
expect(response).to be_successful
end
end
end
Good Example
describe "Payments" do
# user is used by ALL child contexts (via sign_in)
let(:user) { create(:person) }
describe "POST /payments" do
before { sign_in(user) }
# All tests in this context use the same setup
context "with valid params" do
it "creates payment" do
post "/payments", params: { amount: 1000 }
expect(response).to be_successful
end
end
context "with invalid params" do
it "returns error" do
post "/payments", params: { amount: -100 }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "PATCH /subscriptions/:id/payment_method" do
# Define these here since all tests in THIS context need them
let(:subscription) { create(:subscription, person: user) }
let(:payment_method) { create(:payment_method, person: user) }
before { sign_in(user) }
it "updates the payment method" do
patch "/subscriptions/#{subscription.id}/payment_method",
params: { payment_method_id: payment_method.id }
expect(response).to be_successful
end
end
describe "GET /invoices/:id" do
# Define invoice dependencies here since this context needs them
let(:subscription) { create(:subscription, person: user) }
let(:invoice) { create(:invoice, subscription: subscription) }
before { sign_in(user) }
it "displays invoice details" do
get "/invoices/#{invoice.id}"
expect(response).to be_successful
end
end
end
Rationale
I want to look at the context and see 90% of its logical content, so that I don't have to scroll 400 lines above, and then 200 more, just to understand the setup.
— Reader comment in a Reddit AMA discussion with Rspec maintainers
- No Mystery Guests: definitions are nearby (in that context or one level up), so you don't have to scroll far to find what's available, and hidden complexity is reduced
- Self-documenting – each context shows exactly what data it needs, making the test's requirements immediately visible
- Maintenance is easier and better git diffs – changes are localized to the relevant context rather than depending on top-level shared setup, so they only affect tests that actually need that data, and it makes code review easier
- Rule 4 violations set the stage for Rule 3 violations
Rule 5: No actions in let
Don't use let for imperative operations. Use before blocks or inline the action.
Bad Example
let(:user) { create(:user) }
# Unclear execution timing and potentially unclear memoization behavior
let(:process_payment) { PaymentProcessor.charge(user, amount: 100) }
it "charges the user" do
expect(process_payment).to be_successful
expect(process_payment.amount).to eq(100)
end
it "records the transaction" do
# With let's lazy evaluation, does this create a new charge or use
# the same one as above? (it actually is new, but it's not obvious)
expect(process_payment.transaction_id).to be_present
end
Good Example
# Option 1: before block (when action is needed by multiple tests)
let(:user) { create(:user) }
before do
# No lazy evaluation (timing is clear and predictable)
@result = PaymentProcessor.charge(user, amount: 100)
end
it "charges the user" do
expect(@result).to be_successful
expect(@result.amount).to eq(100)
end
it "records the transaction" do
expect(@result.transaction_id).to be_present
end
# Option 2: inline (best when action is only needed by one test)
it "charges the user" do
result = PaymentProcessor.charge(user, amount: 100)
expect(result).to be_successful
expect(result.amount).to eq(100)
end
Rationale
letandbeforehave different semantic meanings.letis semantically telling me about a domain object definition.beforeis telling me what actions are going to happen before each of the following specs in the context.
— Aaron Kromer (RSpec core team), GitHub Discussion on RSpec Best Practices
letis for defining values, not performing actions- Lazy evaluation makes it unclear when the action executes (only on first reference)
- Actions with side effects (payments, emails, deletions) should be explicit in tests, not hidden in
letdefinitions
Notes on let! , subject and subject!
Everything above applies to let!, subject, and subject! also, but they merit some additional comments:
let!is eager loaded, which makes Rule 4 even more important when using it. For example, if you’re usinglet!at the top of a long spec file to create a database record, it will be created for every test in the file. If it’s not actually used in many of those tests, you’re decreasing performance and likely increasing CI costs, on top of the other Rule 4 issues.subjectandsubject!are functionally the same as theirletcounterparts. Since the Rspec docs recommend using a named subject, that further narrows any differences in how they’re used. Butsubjectis still useful for its semantic meaning (making it very clear what the test subject is).
Working with Legacy Specs: Decision Tree
Here are some guidelines to follow when adding to or editing an existing spec file that has existing rule violations:
- Adding to a well-organized context? (consistent pattern, reasonable nesting)
→ Follow existing conventions for local consistency - Adding to a messy context? (mixed patterns, deep nesting, many overrides)
→ Use inline setup to keep your code self-contained - Need to test multiple scenarios?
→ Create a new siblingdescribe/contextblock rather than nesting deeper
You can also consider a full refactor. This is generally only worthwhile for spec files with high churn, where developers are regularly contending with pre-existing rule violations. An option to consider is using AI for assistance (you can use the Claude rules below), but careful code review becomes even more important with this approach.
Claude Code skill for let
If you put the content below in a ~/.claude/skills/rspec-let/SKILL.md file Claude should follow the 5 rules when writing tests (or you can invoke the skill directly).
---
name: rspec-let
description: Apply when writing or editing RSpec specs that use `let`, `let!`, `subject`, or `subject!`. Covers when to extract to let, max declarations per context, the no-actions-in-let rule, and how to add to legacy specs without spreading violations.
---
# RSpec `let` Usage Rules
## Rule 1: Inline First, Extract Later
- Start with inline setup in `it` blocks
- Extract to `let` only after 3+ identical uses
- `let` is a refactoring tool, not a starting point
## Rule 2: 1-3 `let` Calls Per Context
- Aim for 1-3 `let` declarations per context
- Never exceed 5 (RuboCop MultipleMemoizedHelpers default)
- Move `let` declarations to narrower child contexts when possible
## Rule 3: Never Override `let` in Child Contexts
- If child contexts need different values, don't define `let` in parent
- Each child context should define its own `let` with the value it needs
- Overriding creates confusion and bugs when contexts are far apart
## Rule 4: Each Context's `let` Must Be Used by All Its Tests
- Only define `let` at a level where ALL tests in that context use it
- Push `let` declarations down to the narrowest context that needs them
- Prevents "mystery guests" and unnecessary database records
## Rule 5: No Actions in `let`
- `let` is for defining values, not performing actions
- Use `before` blocks for imperative operations with side effects
- Actions (API calls, payments, emails) should be explicit, not hidden in `let`
## Applies to `let!`, `subject`, `subject!`
- `let!` is eager-loaded, making Rule 4 violations even more costly (perf)
- Same principles apply to `subject` and `subject!`
## When Editing Legacy Specs
**Goal:** Keep your new code self-contained. Don't spread existing violations.
1. **Adding to a consistent context?** (uniform pattern, overrides at same nesting level)
→ Follow existing conventions
2. **Adding to an inconsistent context?** (mixed patterns, overrides scattered across nesting levels)
→ Use inline setup: `org = create(:organization, ...)` directly in `it` block
3. **Multiple scenarios?** → New sibling `context` block, not deeper nesting
**Always avoid:** adding new `let` at file top-level unless used by most tests.
References
- RSpec
letDocumentation - RSpec Memoized Helpers Documentation
- RSpec Style Guide – DRY
- Better Specs: Use
let - Thoughtbot: "My Issues with Let"
- Thoughtbot: "Let's Not"
- Rspec: The Bad Parts (2022 RubyConf talk)
- DRY vs DAMP in Testing
- Reddit AMA Discussion with Myron Marston on
letusage (RSpec maintainer) - GitHub Discussion: betterspecs – Use let and let!
- GitHub Discussion: rspec-style-guide – Don't Recommend let or let!