Let vs Let! in RSpec: When to Lazily or Eagerly Evaluate Setup
Choose wisely between RSpec’s <code>let</code> and <code>let!</code> to control when data is created. This guide explains lazy vs eager evaluation, shows a real example, and lists trade‑offs so your tests stay fast and reliable.
12 Sept 2026, 05:24 UTC

The Problem
When writing RSpec tests you often need a piece of data that several examples will use. The most common pattern is to declare that data with let or let!. Both look almost identical, but they behave differently under the hood. Choosing the wrong one can make your suite slower, flaky, or harder to read.
Lazy vs Eager Evaluation
let is lazy. The block you give it is not run until the first time the example references the name. After that, the value is memoized for the rest of the example.
let! is eager. The block runs before the example starts, regardless of whether the name is ever used. This guarantees that any side effects happen before the test body executes.
Because both memoize per‑example, you get a fresh value for each example, but the timing of execution differs.
Key Differences in a Table
| Feature | let | let! |
|---|---|---|
| When executed? | First reference in example | Before each example |
| Memoization | Yes (per example) | Yes (per example) |
| Common use‑case | Values that may not be needed in every test | Setup that must run regardless (DB records, global state) |
| Impact on run time | Can reduce work if value unused | Can increase work if many eager blocks exist |
A Concrete Example
Imagine a model Order that triggers a background job when a record is created. We want to test that the job runs, but only for certain contexts.
# spec/models/order_spec.rb
require 'rails_helper'
RSpec.describe Order, type: :model do
let(:user) { create(:user) }
let(:order) { build(:order, user: user) }
# Eagerly create a record that the job relies on
let!(:existing_invoice) { create(:invoice, user: user) }
context 'when the order is saved' do
it 'enqueues a job' do
expect { order.save! }
.to have_enqueued_job(InvoiceCreationJob)
end
end
context 'when the order is not saved' do
it 'does not enqueue a job' do
expect { order.valid? }
.not_to have_enqueued_job(InvoiceCreationJob)
end
end
end
In the example above:
let(:user)is lazy—if an example never touchesuser, the user never gets created.let!(:existing_invoice)runs before each example, guaranteeing that the invoice exists for the job to reference.- Both
orderanduserare memoized once they are first called.
To verify lazy behavior, run:
bundle exec rspec spec/models/order_spec.rb
Check the console logs or database count: the user record appears only when an example references user. The invoice record appears in every example regardless.
Trade‑offs & Limitations
Using let! can hide side effects. If a let! block modifies a global variable or triggers a network call, the test may pass or fail depending on the order of examples. RSpec runs examples in random order by default, so hidden dependencies can cause flaky tests.
Conversely, relying on let for side‑effecting code can also be risky. If a test never references the let but still expects the side effect, the test will silently pass because the block never ran.
Best practice: reserve let! for setup that must happen before the example, such as database records that a model callback relies on. Use before(:each) for side effects that should always run, and keep let for values that are genuinely optional or expensive to compute.
Actionable Takeaway
When deciding between let and let!:
- Ask: *Does this value need to exist before the example runs?* If yes, use
let!. - Otherwise, use
letto keep the suite fast and avoid unnecessary work. - Always document the purpose of a
let!block in the test description; a comment helps future maintainers understand why the eager evaluation is required. - Run
bundle exec rspec --profileto spot any examples that spend more time creating data than they actually use.
Following these guidelines will keep your RSpec suite both performant and reliable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.