Question
In RSpec, I usually use before blocks to assign instance variables, and then I reuse those variables across my examples.
Recently, I came across let. The RSpec documentation says it is used to define a memoized helper method:
It defines a memoized helper method. The value will be cached across multiple calls within the same example, but not across examples.
How is let different from using instance variables set in before blocks? Also, when should you use let instead of before {} in RSpec?
Short Answer
By the end of this page, you will understand what let does in RSpec, how it differs from instance variables created in before blocks, and when each approach is the better choice. You will also learn about lazy evaluation, memoization, test readability, and common setup patterns used in real Ruby test suites.
Concept
let and before both help prepare data for RSpec examples, but they solve slightly different problems.
What before does
A before block runs setup code before each example. It is useful when you want to perform actions that must always happen before the test runs.
before do
@user = User.new(name: "Ava")
end
Here, @user is an instance variable created before every example.
What let does
let defines a helper method whose value is computed only when you first use it inside an example. After that first call, the value is memoized, which means the same value is returned every time you call it again in that example.
let(:user) { User.new(name: "Ava") }
If an example never calls user, the block never runs.
The key differences
1. Execution timing
beforeruns before every example, whether the setup is needed or not.letruns only when referenced in the example.
2. Access style
beforeoften sets instance variables like@user.letcreates a method likeuser.
3. Memoization
letcaches its value within a single example.- Instance variables in
beforeare assigned once before the example and then reused, but they do not provide the same clear helper-method style.
4. Readability
let(:user)clearly declares a named test dependency.@usercan be less explicit, especially in large specs or nested contexts.
Why this matters
In real test suites, clear setup makes tests easier to read and maintain. let often improves clarity because it behaves like a named input to the test. before is better for side effects or required actions, such as making requests, saving records, stubbing methods, or configuring state that must happen before assertions.
Mental Model
Think of before as preparing the whole kitchen before cooking.
- You take out ingredients.
- You heat the oven.
- You place tools on the counter.
- This happens every time, whether you use everything or not.
Think of let as a labeled container in the kitchen.
- You define that a container called
userexists. - You only open and use it if the recipe needs it.
- Once opened during that recipe, you keep reusing the same container.
So:
before= do setup work ahead of timelet= define a reusable value that is created only when needed
Syntax and Examples
Basic before
RSpec.describe User do
before do
@user = User.new(name: "Ava")
end
it "has a name" do
expect(@user.name).to eq("Ava")
end
end
This runs the before block before the example, so @user is available inside the test.
Basic let
RSpec.describe User do
let(:user) { User.new(name: "Ava") }
it "has a name" do
expect(user.name).to eq("Ava")
end
end
This defines a method called user. The User.new(...) code runs only when user is first called.
Memoization example
RSpec.describe "let memoization" do
let(:value) do
puts "building value"
[1, 2, 3]
end
it "uses the same object within one example" do
a = value
b = value
expect(a.object_id).to eq(b.object_id)
end
end
The message building value is printed only once in that example, even though value is called twice.
before for side effects
RSpec.describe "account setup" do
before do
@account = Account.create!(name: "Main")
@account.activate!
end
it "is active" do
expect(@account).to be_active
end
end
This is a good case for before, because the block performs actions, not just value definition.
let! for eager evaluation
RSpec also provides let!, which forces evaluation before each example.
RSpec.describe Order do
let!(:order) { Order.create!(number: "A123") }
it "creates an order before the example runs" do
expect(Order.count).to eq(1)
end
end
Use let! when you want the cleaner style of let but need the value created before the example starts.
Step by Step Execution
Consider this example:
RSpec.describe "let behavior" do
let(:number) do
puts "computing number"
10 * 2
end
it "uses the value" do
expect(number + number).to eq(40)
end
end
What happens step by step
-
RSpec starts the example.
-
The
let(:number)block has been defined, but it has not run yet. -
The example executes
number + number. -
On the first call to
number, RSpec runs the block:puts "computing number" 10 * 2 -
The result
20is stored for this example. -
On the second call to
number, RSpec returns the cached value20. -
The expression becomes
20 + 20, which equals40. -
The expectation passes.
Compare with before
RSpec.describe "before behavior" do
before do
puts "computing number"
@number = 10 * 2
end
it "uses the value" do
expect(@number + @number).to eq(40)
end
end
Here is the flow:
- RSpec starts the example.
- The
beforeblock runs immediately. @numberis assigned20.- The example executes.
- The expectation uses
@number.
The important difference is that before runs regardless of whether the example actually uses @number.
Real World Use Cases
When let is useful
Defining test data used by some examples
let(:user) { User.new(name: "Ava") }
Useful when:
- only some examples need the object
- the setup is just a value
- you want readable test inputs
Building related objects
let(:user) { create(:user) }
let(:post) { create(:post, user: user) }
This is common in Rails apps using factories.
Reusing computed values
let(:json) { JSON.parse(response.body) }
Good when parsing response data once and reusing it in several expectations.
When before is useful
Making requests
before do
get "/api/profile"
end
The request is an action that should happen before assertions.
Stubbing or configuring behavior
before do
allow(Time).to receive(:now).and_return(Time.new(2024, 1, 1))
end
Performing required side effects
before do
user.activate!
end
This is not just data definition; it changes state.
When let! is useful
Use let! when the object must exist before the example, even if not directly referenced first.
let!(:user) { create(:user) }
For example, if your test checks database count or relies on callbacks that should already have happened.
Real Codebase Usage
In real Ruby and Rails codebases, teams often follow a simple rule:
- Use
letfor values - Use
beforefor actions
Common patterns
1. Named test inputs with let
let(:params) { { email: "a@example.com", password: "secret" } }
let(:user) { create(:user) }
This makes specs read more like documentation.
2. Guarding setup cost
If object creation is expensive, let avoids unnecessary work in examples that do not use the object.
let(:report) { build_large_report(data) }
3. Request specs
let(:headers) { { "Authorization" => token } }
let(:token) { "abc123" }
before do
get "/api/items", headers: headers
end
Here, let defines data and before performs the request.
4. Validation and context-specific overrides
let(:name) { "Ava" }
let(:user) { User.new(name: name) }
context "when the name is blank" do
let(:name) { "" }
it "is invalid" do
expect(user).not_to be_valid
end
end
This is one of the biggest advantages of let: nested contexts can override values cleanly.
5. Cleaner than shared instance variables
In larger specs, @user, @account, and @response can become hard to track. let makes dependencies more explicit because each value has a named definition.
Common Mistakes
1. Using before for simple values when let is clearer
Less clear
before do
@user = User.new(name: "Ava")
end
Clearer
let(:user) { User.new(name: "Ava") }
If the setup is just defining a value, let is usually easier to read.
2. Forgetting that let is lazy
Broken expectation:
let(:user) { User.create!(name: "Ava") }
it "creates the user" do
expect(User.count).to eq(1)
end
This may fail because user was never called.
Fix
it "creates the user" do
user
expect(User.count).to eq(1)
end
Or use let!:
let!(:user) { User.create!(name: "Ava") }
3. Overusing instance variables across many contexts
before do
@user = create(:user)
@post = create(:post, user: @user)
@comment = create(:comment, post: @post)
end
This can make tests harder to follow. Prefer smaller named helpers with let.
4. Mixing side effects into let
Possible but less clear:
let(:user) do
u = User.create!(name: "Ava")
u.activate!
u
end
This works, but if activation is the important action, separating value definition and setup is often better.
let(:user) { User.create!(name: "Ava") }
before do
user.activate!
end
5. Assuming let shares values across examples
It does not. Each example gets its own memoized value.
let(:numbers) { [] }
it "adds a number" do
numbers << 1
expect(numbers).to eq([1])
end
it "starts fresh in the next example" do
expect(numbers).to eq([])
end
Comparisons
| Feature | let | before |
|---|---|---|
| Purpose | Define a value/helper | Perform setup actions |
| Runs when | On first use in an example | Before every example |
| Memoized | Yes, within one example | Not a helper method; just setup assignment |
| Good for | Test data, computed values, readable dependencies | Side effects, stubs, requests, required state changes |
| Can be unused without cost | Yes | No |
| Easy to override in nested contexts | Yes | Less clean with instance variables |
let vs let!
| Feature | let | let! |
|---|---|---|
| Evaluation | Lazy | Eager |
| Runs if never referenced | No | Yes |
| Best for | Optional values | Required pre-created state |
Rule of thumb
- Use
letfor data. - Use
beforefor actions. - Use
let!when data must be created before the example runs.
Cheat Sheet
Quick rules
let(:name) { value }defines a memoized helper method.- The
letblock runs only when first called in an example. - The result is cached for the rest of that example.
- A new value is created for each example.
beforeruns before every example.- Use
beforefor actions and side effects. - Use
let!when you want eager evaluation.
Common syntax
let(:user) { User.new(name: "Ava") }
let!(:account) { Account.create!(name: "Main") }
before do
allow(Time).to receive(:now).and_return(Time.new(2024, 1, 1))
end
Good defaults
- Prefer
letover instance variables for simple test data. - Prefer
beforefor requests, stubs, setup actions, and mutations. - Keep each helper focused and easy to name.
- Override
letin nestedcontextblocks when needed.
Important edge case
If you expect a database record to exist before the example body runs, let alone may not be enough. Use let! or explicitly call the helper first.
FAQ
What is the main difference between RSpec let and before?
let defines a lazily evaluated, memoized helper method. before runs setup code before each example.
Is let better than instance variables in RSpec?
Often yes for simple test data, because it is more explicit, easier to override, and usually easier to read.
When should I use before instead of let?
Use before when you need to perform actions such as making a request, stubbing a method, or changing object state before the example runs.
Why does my let block not run?
Because let is lazy. It only runs when the helper method is called in the example.
What does let! do in RSpec?
let! works like let, but it evaluates before each example, even if you never call it directly.
Can I override let in nested contexts?
Yes. That is one of its biggest benefits in RSpec.
Does let share data between examples?
No. Each example gets its own memoized value.
Mini Project
Description
Create a small RSpec example suite for a User class to practice the difference between let, let!, and before. This project demonstrates how to define test data clearly, how lazy evaluation works, and how setup actions differ from value definitions.
Goal
Write a spec that uses let for test data, before for setup actions, and let! for eager creation when required.
Requirements
- Create a simple
Userclass withnameandactiveattributes. - Write one spec that uses
letto define a user. - Write one spec that uses
beforeto activate the user before the example. - Write one spec showing that
letis lazy. - Write one spec showing that
let!runs before the example.
Keep learning
Related questions
Add a Default Value to an Existing Column in a Rails Migration
Learn how to add or change a default value on an existing database column using Rails Active Record migrations safely.
Calling a Class Method from an Instance in Ruby
Learn how to call a class method from an instance in Ruby using self.class, with examples, pitfalls, and practical usage patterns.
Calling an Overridden Monkey-Patched Method in Ruby
Learn how to call the original method when monkey patching in Ruby, including alias_method patterns, examples, pitfalls, and practical usage.