RSpec: 5 rules for using let effectively

let can 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 Tests

In general, it is best to start with doing everything directly in your it blocks 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

  • let is 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

let can 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: let obscures test dependencies. Thoughtbot documented a test that "referenced 18 other let statements, 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 let makes fixtures reusable, teams gradually add more setup to shared let blocks, 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 let only 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 let value 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

let and before have different semantic meanings. let is semantically telling me about a domain object definition. before is 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

  • let is 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 let definitions

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 using let! 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.
  • subject and subject! are functionally the same as their let counterparts. Since the Rspec docs recommend using a named subject, that further narrows any differences in how they’re used. But subject is 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:

  1. Adding to a well-organized context? (consistent pattern, reasonable nesting)
    → Follow existing conventions for local consistency
  2. Adding to a messy context? (mixed patterns, deep nesting, many overrides)
    → Use inline setup to keep your code self-contained
  3. Need to test multiple scenarios?
    → Create a new sibling describe/context block 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

Leave a Reply