RSpec let vs let!: lazy setup, memoization, and the spec that never ran
let is lazy and memoized per example; let! adds a before hook. How the mechanism works, where it bites, and a two-minute check to verify it in your suite.
01 Jul 2025, 07:27 UTC

The classic symptom: a spec asserts User.count is 1, the factory line sits right above the expectation, and the count comes back 0. Nothing is wrong with the factory. The helper that creates the user is a let, and nothing in the example ever referenced it — so the block never ran.
The takeaway: let defines a helper method that is lazily evaluated and memoized for the duration of a single example. let! is the same helper plus a before hook that calls it before the example body. Use let when the example reads the value; use let! when the example depends on the side effect — a created row, a written file, a stubbed clock — happening first.
What let actually does
Under RSpec 3.x (the version line most suites run today — confirm yours with bundle exec rspec --version), a let declaration defines a method on the example group. The first call to that method within an example runs the block and caches the result. Every later call in the same example returns the cached object without re-running the block. When the example ends, the cache is discarded, so the next example re-runs the block from scratch.
let! is sugar for two things at once: the same lazy helper, plus a before hook that references it. Writing let!(:line) { ... } behaves as if you had written let(:line) { ... } followed by before { line }. One consequence follows from this: if a let! block references another helper, that helper is forced too, transitively, during the before hook.
A worked example: which helpers need the bang
Here is a small Rails-flavored spec for a service that totals an order. Amounts are in cents.
RSpec.describe OrderTotal do
let(:order) { Order.create!(status: "pending") }
let!(:line) { order.line_items.create!(price_cents: 2500, quantity: 2) }
let(:total) { described_class.new(order).amount }
it "sums the line items" do
expect(total).to eq(5000)
end
it "excludes cancelled lines" do
line.update!(cancelled: true)
expect(total).to eq(0)
end
endThree deliberate decisions are packed into those declarations:
lineneedslet!. The second example mutates it, andtotal's block readsorder.line_items, so the row must exist before either runs. Becauseline's block referencesorder, thelet!before hook also forcesorderto be created, in dependency order.orderstays a plainlet. Every example here reaches it anyway, throughlineortotal, so laziness costs nothing — and nested examples could still customize it before first use.totalis a plainlet, and that choice creates an ordering contract. In the second example,line.update!happens beforetotalis first referenced, so the cached total reflects the cancellation. Swap those two lines and the first reference caches the pre-cancellation value.
Choosing between let, let!, before, and a plain method
| Declaration | When the block runs | Runs if never referenced? | Good fit |
|---|---|---|---|
let(:x) | First reference inside the example | No | Values the example reads |
let!(:x) | In a before hook, before the example body | Yes, for every example in scope | Side-effectful setup the example depends on |
before block | Before the example body | Yes | Multi-step imperative setup |
| Plain method | On every call, no caching | No | Values you want recomputed each call |
The rule of thumb: let for data, let! for side effects, before for sequences of steps, and a plain helper method when memoization itself would be the bug — for example, building a distinct record on each call inside a loop.
Limits worth knowing
- Memoization is strictly per example. You cannot share a
letvalue across examples, and calling alethelper frombefore(:context)— which runs once per group, outside any example — fails, because there is no per-example cache to store the result in. - A
let!declared high in a describe block runs for every nested example, including ones that never touch the value. With expensive factories that is pure overhead; scope eager helpers as narrowly as the examples that need them. - Nested contexts can redefine a
letto specialize it. That is a useful override pattern, but the redefinition silently changes every example in that subtree, including ones written against the parent's version. letgives you per-example isolation, but it cannot protect state that escapes RSpec's lifecycle. A class variable, a memoized connection, or a global cache still leaks between examples no matter how carefully you use helpers.
Common mistakes
- Setup that never runs. The most frequent failure mode, and the one from the opening:
let(:user) { create(:user, admin: true) }
it "lists only admins" do
expect(User.where(admin: true).count).to eq(1) # fails: user was never created
endThe example never references user, so the block is skipped. Either reference it or declare it with let!.
- Stale memoized values after mutation. If an example reads
totalbefore mutatingline, later references in that same example still see the old total. Order your reads deliberately, or use a plain method for derived values you expect to recompute. - Hidden evaluation-order dependencies.
let!(:line) { order.line_items.create!(...) }forcesorderas a side effect. If someone later rewritesline's block to build a record directly,order's creation timing shifts, and examples that relied on it start failing in confusing ways. Keep chains between helpers shallow, and reference the dependency explicitly in the example when the ordering matters. - Reaching for let to share state across examples. It will not happen — each example gets a fresh cache. If you genuinely need once-per-group setup, that is
before(:context), with the trade-off that its state is shared and mutations leak across the whole group.
Verify the semantics in your own suite
A two-minute check makes the lazy behavior visible. Add a print to the block:
RSpec.describe "let timing" do
let(:order) do
puts " building order"
:order
end
it "runs the block on first reference only" do
puts "example starts"
order
order
puts "example ends"
end
endRun it with bundle exec rspec spec/path/to/that_spec.rb from your project root. Under the documented RSpec 3.x semantics you should see example starts, then building order exactly once, then example ends. If building order appears before example starts, the helper is being forced somewhere — look for a stray let! or a before hook referencing it.
A second check confirms per-example freshness: write two examples that each reference the same helper and assert the underlying record count is 1, with your usual transactional cleanup between examples. If the second example sees a count of 2, something is sharing state outside let — class variables and connection-level caches are the usual suspects.
Finally, confirm the version you are actually running: check rspec-core in Gemfile.lock or run bundle exec rspec --version. This article assumes the RSpec 3.x line; if you are tracking newer rspec-core releases, check its changelog before relying on these details.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.