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
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 is first called.
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 value .
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.
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, defines data and performs the request.
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
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!
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.
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.
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
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.
Difference Between require and include in Ruby
Learn the difference between require and include in Ruby, when to load a file, and when to mix module methods into a class.
Fixing Ruby Gem Native Extension Errors: mkmf.rb Can't Find Header Files for Ruby
Learn why Ruby gem installs fail with missing ruby.h, how native extensions work, and how to fix header file errors on Linux servers.