In RSpec, how do let, let! and an instance variable assigned in a before hook differ in when setup runs?
answer
- lazy versus eager
- memoized once per example
- let! adds a before hook where declared
- misspelled @ivar quietly returns nil
- not usable inside before(:context)
basics
~20 slet 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 linesRSpec.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
endgo deeper
Know that let is lazy and cached for one example, let! runs before every example, and before hooks with instance variables always run.
Explain the memoization per example, the before hook that let! registers at its declaration point, and how a nested context overrides a let.
Spot hook-order bugs caused by let! placement, and move expensive eager setup to lazy let without breaking examples that need the side effect.
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)