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

Rails views, internationalization, special characters, and testing with Rspec

The problem

File this under small problems that take more time than they should to solve, and I couldn’t find an answer with a web search.

Let’s use a simple example. If you have text like this in your translation file (e.g. en.yml):

users:
  new:
    header: "Let's go!"

And then show it in a view template (e.g. app/views/users/new.html.erb):

<h3><%= t('.header') %></h3>

And then try to match it in an Rspec test, you’ll get an error that it couldn’t be found:

expect(response.body).to include(I18n.t('users.new.header'))

Failure/Error: expected "[...]Let&#39;s go![...]" to include "Let's go!"

Rspec with Rails 7 and System Tests

Hello world! It’s time for my first post in over 4 years.

I recently set up a new Rails 7 project with Rspec and looked online for tips, as one does. I’ve set up many Rails projects before, but not yet with Rails 7, and it’s been a while. The top result in Google for “rails 7 with rspec” is currently Adrian Valenzuela’s Setup RSpec on a fresh Rails 7 project. His post was really helpful for me shaking off the rust. So rather than writing another post that’s 80% the same, I’ll just share a few additional tips. Think of this post as a companion piece to Valenzuela’s.

Terry Toppa, 1939-2021: a remembrance of my father

My father passed away on May 10 last year, after a short and unexpected battle with cancer (aside from some back pain, he was doing fine just a few weeks earlier). I wrote his obituary the next day. There was a short graveside committal service, where I also had the opportunity to say a few words about him. I want to share those words here, and his obituary. Two days ago, March 10, he would have been 83.

“Magic: The Gathering” Standard deck brew – Jeskai Pirate Aggro

This is my first post about Magic: The Gathering, which I’ve been playing for years. If you have no idea what I’m talking about, I highly recommend this New Yorker article about the history and culture of the game or if you prefer audio, this episode of Planet Money from NPR, about how the game has managed to stay popular for over 25 years.

Ever since Rivals of Ixalan came out about a year ago, Path of Mettle has been my favorite card to try to build around. It’s a finicky card that requires your deck to be stacked with the specific types of creatures it needs, but the payoff is that, once transformed into Metzali, Tower of Triumph, it’s “a one-card, synergistic game-ender,” as Craig Krempels put it. I can’t resist trying to make a card like that work. You see Field of Ruin rarely these days, Teferi, Hero of Dominaria can’t touch it, and it can take down a Carnage Tyrant (since its ability doesn’t target). The one damage spread across the board by Path of Mettle entering the battlefield is also highly relevant in the current metagame, with a lot of one toughness creatures running around in mono-blue, white aggro, and token decks (it also hits Llanowar Elves in Sultai and Pteramander in Drakes). Of course the trick is, any competitive deck can’t rely on one card – you still need to be able to win without it, and I’ve been getting good results with this build, which I’ve been iterating on for a while.

Ad for Sugar in 1966 Issue of Time

1966 ad for sugar in Time magazine

1966 ad for sugar in Time magazine

In 1995 I photocopied this ad from a 1966 issue of Time magazine. I was in grad school doing some research on the Vietnam war, and couldn’t help but notice it. It’s almost as over the top as the old Saturday Night Live fake ad for speed. I thought I lost the photocopy years ago, but found it in a box in my basement the other day.

If you can’t make out the “Note to Mothers” at the bottom, it says:

Note to Mothers: Exhaustion may be dangerous – especially to children who haven’t learned to avoid it by pacing themselves. Exhaustion opens the door a little wider to the bugs and ailments that are always lying in wait. Sugar puts back energy fast – offsets exhaustion. Synthetic sweeteners put back nothing. Energy is the first requirement of life. Play safe with your young ones – make sure they get sugar every day.

RubyConf 2018 is about to start, so let’s talk about RubyConf 2017!

RubyConf 2018 starts tomorrow, and just like I did with RailsConf, I’m very belatedly going to share some highlights from RubyConf 2017, which was in New Orleans last November. It was my first time attending RubyConf, and what struck me the most was the really strong sense of community. Here’s what one first-time attendee had to say:

…This conference was so incredibly worth it. I learned about sweet gems, cool projects, and job opportunities. But more importantly, I met SO MANY totally epic and amazing individuals that even after only three short days I happily now consider friends. I cannot wait to follow their coding lives and journeys in the years to come. I am confident that so many of them are going to do great and groundbreaking things. Plus, I cannot WAIT for my next RubyConf.

That’s from the post 31 thoughts I had while attending my first #RubyConf as an Opportunity Scholar. RubyConf’s Opportunity Scholar program provides financial support for folks who wouldn’t be able to attend otherwise, and are getting started with Ruby. The Scholars are then each matched with a Guide – experienced people who can help them navigate the conference, and make connections for professional development and job opportunities. I applied to be a Guide for this year’s RubyConf and I was selected – I’m looking forward to it!

RubyConf has three tracks of talks, so it’s not possible to attend them all, but here are the ones that were my favorites, including links to the videos for each of them:

Watch Fish Story (フィッシュストーリー), Right Now

Fish Story movie poster

Fish Story movie poster

If you might like a movie that is equal parts…

  • Memento: but instead of the story unfolding in reverse, it unfolds in a completely jumbled sequence, going from 2012, to 1982, to 2009, to 1975, and then back to 2012. If you enjoy a movie that calls for your active mental participation, and you appreciate the movie maker’s attention to detail in making all the seemingly disparate threads of a story mesh together, then Fish Story is for you.
  • Anvil! The Story of Anvil: except instead of a story about a briefly famous band that falls into obscurity, the band in this story, Gekirin, goes from obscurity to oblivion. They write a punk song in 1975 that is ahead of its time, that almost no one appreciates, but ultimately is the key to saving the world (yes, punk rock can save the world, and fortunately, they actually wrote a great track for the movie).
  • Armageddon: in 2012, the destruction of life on earth by asteroid is imminent. Last ditch attempts to save humanity, involving space ships and nuclear warheads, are involved. If you’re wondering what a forgotten punk rock song from the 70s has to do with saving the earth from an asteroid 37 years later, well you’ll just have to watch the movie!
  • The Karate Kid and Power Rangers: a young man who isn’t sure why his father forced him to endlessly practice martial arts as a child finally finds his purpose.
  • High Fidelity: the cool record store owner in this movie has the same encyclopedic knowledge of music as John Cusack’s character, but his sadness does not come from girl troubles.
  • If You Give a Pig a Pancake (which is a children’s book, not a movie): after watching Fish Story, you might start thinking about causality, conditionality, and contingencies, but all I could think of was this book. Each step in the story makes sense by itself, but they all add up to a crazy spectrum of events.
  • …then you will enjoy Fish Story.

One Day in Tokyo: Asakusa, and a River Cruise to Odaiba

If you have the misfortune of visiting Tokyo for only a few days, you’ll find it hard to decide where to spend your time in a city that has so many amazing things to see and do. A good way to get a sense of the traditional, slower-paced Tokyo, as well as the modern, fast-paced Tokyo in a single day is to venture to the northeastern district of Asakusa in the morning, with its temples and buildings dating back to the 1950s (Tokyo was essentially leveled in the WWII fire-bombings, so the 50s is considered old for Tokyo architecture). Then take a cruise south on the Sumida river, which will take you under about a dozen architecturally distinct bridges. The cruise ends on the man-made island of Odaiba in Tokyo Bay, which offers endless attractions for modern shopping and hi-tech fun, and even a sandy beach. At the end of the day (or night), head back to the mainland on the Yurikamone line, which does an entirely gratuitous 360° loop as it crosses the river, giving you a panoramic view of eastern Tokyo.

RailsConf 2017 in tweets, and my “Why Do Planes Crash?” lightning talk

RailsConf 2018 starts in exactly one month, and I’m looking forward to it! This means I should probably get around to saying something about RailsConf 2017. The video above is cued to start at the beginning of a lightning talk I gave. The title was “Why Do Planes Crash? Lessons for Junior and Senior Developers.” Analyses of plane crashes show planes actually crash more often when the senior pilot is in the flying seat, often because junior pilots are reticent to speak up when they see problems, while senior pilots don’t hesitate to do so when the junior pilot is flying. There are some great lessons developers can apply from this for how to do mentoring and pair programming.

The lightning talks were at the end of the 2nd day, and I made a last minute decision that morning to sign up and put a talk together. I’ve given a number of conference talks before, but never to a crowd this big, and never with so little time to prepare. Then when it was time to give the talk, there was a technical issue that prevented me from seeing my notes, so I had to wing it. Under the circumstances I think it still turned out ok. Here are my slides (they’re also embedded below) and some tweets about the talk: