skip to content

In RSpec, how do let, let! and an instance variable assigned in a before hook differ in when setup runs?

level: middleimportance: must knowfreq 74%

answer

  1. lazy versus eager
  2. memoized once per example
  3. let! adds a before hook where declared
  4. misspelled @ivar quietly returns nil
  5. not usable inside before(:context)

basics

~20 s

let is lazy, running its block on first call and memoizing the value for one example. let! also adds a before hook, so it always runs. An instance variable set in before always runs too, and reads as nil if misspelled.

solid answer

~50 s

`let(:list) { ShoppingList.new }` defines a method on the example group. The block runs the first time an example calls `list`, and the result is cached for the rest of that example; the next example gets a fresh value. If no example calls it, it never runs. `let!` is `let` plus a `before` hook that calls the helper, registered at the point of declaration, so the object is created before every example and in declaration order with the other `before` hooks; use it when a side effect must exist without being referenced, such as a saved record a query should find. An instance variable assigned in `before` also runs every time, but a typo like `@lsit` is silently `nil`, whereas a misspelled `let` name raises `NameError`. Inner contexts can override a `let`, and neither `let` nor `subject` may be called from `before(:context)`.

code

ruby · 12 lines
ruby
RSpec.describe ShoppingList do
  let(:list) { ShoppingList.new }
  let!(:saved) { ShoppingList.create(name: "weekly") }

  it "builds list only when called" do
    expect(list.items).to be_empty
  end

  it "finds the saved list without mentioning it" do
    expect(ShoppingList.find_by_name("weekly")).not_to be_nil
  end
end

go deeper

for a junior

Know that let is lazy and cached for one example, let! runs before every example, and before hooks with instance variables always run.

for a middle

Explain the memoization per example, the before hook that let! registers at its declaration point, and how a nested context overrides a let.

for a senior

Spot hook-order bugs caused by let! placement, and move expensive eager setup to lazy let without breaking examples that need the side effect.

for a principal

Weigh a team convention on let versus explicit setup inside examples, trading duplication against how far a reader must scroll to find state.

## Three ways to prepare state Specs for a `ShoppingList` model need an object to work on. RSpec offers three common ways to provide it, and they differ in **when** the setup code runs and **how mistakes surface**. | | `let(:list) { ... }` | `let!(:list) { ... }` | `before { @list = ... }` | |---|---|---|---| | Runs | on first call in an example | before every example | before every example | | Runs if unused | no | yes | yes | | Cached within one example | yes | yes | it is a plain variable | | Fresh for the next example | yes | yes | yes | | Typo in the name | `NameError` | `NameError` | silently `nil` | | Overridable in a nested group | yes, with `super()` available | yes | only by reassigning in another hook | ## How let works `let(:list) { ShoppingList.new }` defines an instance method called `list` on the example group. The method wraps the block in **memoization**: 1. The first call inside an example runs the block and stores the result. 2. Later calls in the same example return the stored object, so `list.add("milk")` followed by `list.items` operate on the same list. 3. The next example runs on a new group instance with an empty cache, so it gets a new `ShoppingList`. Because the value is computed on demand, `let` is **lazy**: an example that never mentions `list` never builds one. That keeps expensive setup off examples that do not need it. By default the cache is guarded by a mutex (`config.threadsafe` is true), so an example that touches the helper from several threads still builds it once. ## How let! works `let!` is literally `let` followed by a `before` hook that calls the helper. Two consequences follow: - The block runs before **every** example in the group, even ones that never reference it. That is the point: use `let!` when the object must exist as a side effect, for example a stored record that a lookup method under test should find. - The hidden hook is registered **where the `let!` line appears**, so it runs in declaration order with the group's other `before` hooks. A `before` block written above a `let!` runs first and cannot see that object yet unless it calls the helper itself. ## Instance variables in before `before { @list = ShoppingList.new }` is the oldest style. It works, and every example gets a new object because each example runs on its own group instance, but it has two weaknesses: - It always runs, like `let!`, even for examples that do not need it. - Ruby returns `nil` for an instance variable that was never assigned, so a misspelling such as `@lsit.add("milk")` fails later with `NoMethodError` on `nil` instead of pointing at the typo. A misspelled `let` name fails immediately with `NameError`. ## Overriding and super A `let` is a method on a class, and a nested `context` is a subclass, so an inner group can redefine the helper: ```ruby RSpec.describe ShoppingList do let(:items) { [] } let(:list) { ShoppingList.new(items) } context "with milk already listed" do let(:items) { ["milk"] } it "does not duplicate it" do list.add("milk") expect(list.items).to eq(["milk"]) end end end ``` The outer `list` definition calls `items`, and method lookup finds the inner override. Inside an overriding block, `super()` returns the outer value, which lets a context extend rather than replace it. ## Where let cannot be used - In a `before(:context)` or `after(:context)` hook: `let` and `subject` exist to create state that resets between examples, and RSpec raises an error naming the declaration if one is accessed there. - In the group body: the helper exists only while an example runs, and RSpec raises `WrongScopeError` when the group body calls it. - As a reserved name: RSpec refuses `let(:initialize)` and `let(:to_s)` with `ArgumentError`. ## A hook-order trap with let! Because the hidden hook of `let!` sits at its declaration line, moving a `let!` below a `before` that depends on it changes behaviour without any visible error. If the `before` reads the helper by name it still works, since calling the helper builds the value on demand; if it expects some side effect of the `let!` block to have happened already, it silently runs too early. Reading hooks top to bottom within a group is the reliable mental model. ## Choosing Prefer `let` for objects the examples use, `let!` only when existence itself is the setup, and a `before` block for actions (calling a method, changing configuration) rather than for building values.

  • Within one example, how many times does a let block run if the example calls the helper three times?
    Once. The first call runs the block and caches the result for the rest of that example; the other two calls return the cached object. The next example starts with an empty cache and runs the block again.
  • A before block declared above a let! searches for the record that the let! block saves, and finds nothing. Why?
    `let!` registers its hidden `before` hook where it is declared, and hooks in one group run in declaration order. The earlier `before` runs first, before the `let!` block has saved anything. Calling the helper by name from that hook would build it on demand, or the `let!` can move above it.
  • Why is let preferred over instance variables in before for values?
    It is lazy, so unused setup never runs; a misspelled name raises `NameError` instead of returning `nil`; and it can be overridden per context like a method, with `super()` reaching the outer definition.

saying these in an interview costs you the question

  • let runs its block before every example like a before hook
  • let! is lazy and only runs when the example calls it
  • a let value is shared by all examples in the group
  • a misspelled instance variable raises an error straight away
  • let values are the right way to share setup in before(:context)