Question

When to Use RSpec let vs before in Ruby Tests

rubyrspec

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

  • before runs before every example, whether the setup is needed or not.
  • let runs only when referenced in the example.

2. Access style

  • before often sets instance variables like @user.
  • let creates a method like user.

3. Memoization

  • let caches its value within a single example.
  • Instance variables in before are 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.
  • @user can 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 user exists.
  • 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 time
  • let = define a reusable value that is created only when needed
Take Quiz

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

  1. RSpec starts the example.

  2. The let(:number) block has been defined, but it has not run yet.

  3. The example executes number + number.

  4. On the first call to number, RSpec runs the block:

    puts "computing number"
    10 * 2
    
  5. The result 20 is stored for this example.

  6. On the second call to number, RSpec returns the cached value 20.

  7. The expression becomes 20 + 20, which equals 40.

  8. 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:

  1. RSpec starts the example.
  2. The before block runs immediately.
  3. @number is assigned 20.
  4. The example executes.
  5. 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.

Take Quiz

Real Codebase Usage

In real Ruby and Rails codebases, teams often follow a simple rule:

  • Use let for values
  • Use before for 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

Featureletbefore
PurposeDefine a value/helperPerform setup actions
Runs whenOn first use in an exampleBefore every example
MemoizedYes, within one exampleNot a helper method; just setup assignment
Good forTest data, computed values, readable dependenciesSide effects, stubs, requests, required state changes
Can be unused without costYesNo
Easy to override in nested contextsYesLess clean with instance variables

let vs let!

Featureletlet!
EvaluationLazyEager
Runs if never referencedNoYes
Best forOptional valuesRequired pre-created state

Rule of thumb

  • Use let for data.
  • Use before for actions.
  • Use let! when data must be created before the example runs.
Take Quiz

Cheat Sheet

Quick rules

  • let(:name) { value } defines a memoized helper method.
  • The let block 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.
  • before runs before every example.
  • Use before for 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 let over instance variables for simple test data.
  • Prefer before for requests, stubs, setup actions, and mutations.
  • Keep each helper focused and easy to name.
  • Override let in nested context blocks 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.

Take Quiz

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 User class with name and active attributes.
  • Write one spec that uses let to define a user.
  • Write one spec that uses before to activate the user before the example.
  • Write one spec showing that let is lazy.
  • Write one spec showing that let! runs before the example.
Take Quiz

Keep learning

Related questions

View more