Choosing RSpec Setup: Lazy let, Eager let!, or before Hooks – A Decision Guide
Decide when to use RSpec's lazy <code>let</code>, eager <code>let!</code>, or imperative <code>before</code> hooks. A decision guide with a comparison table, trade‑offs, and concrete validation steps helps keep tests fast, isolated, and maintainable.
23 May 2026, 19:06 UTC

Problem & Takeaway
When writing RSpec tests you often need to create data that the subject under test will read. The choice between let (lazy), let! (eager), or a before block can affect test isolation, performance, and readability. The key takeaway: use let by default, reserve let! for data that must exist before the example runs, and use before only when you need imperative ordering or multi‑step setup that let cannot express.
Decision & Constraints
Decide based on the following constraints:
- Evaluation timing: Does the data need to exist before the example body runs?
- Memoization: Is it acceptable that the value is recomputed per example?
- Side effects: Does the setup create database state or external resources that must be isolated?
- Override flexibility: Do you need to override the value in nested
contextblocks? - Performance: Will eager setup add unnecessary work for examples that never use the data?
Compact Comparison Table
| Feature | let | let! | before |
|---|---|---|---|
| Evaluation | Lazy – runs on first reference in an example | Immediate – runs before example body, even if never referenced | Imperative – runs at the specified hook point |
| Memoization | Per‑example; cached for the rest of that example | Per‑example; cached after execution | No built‑in memoization; each call creates new state |
| Override in nested groups | Yes – let can be redefined | Yes – redefinition works but execution order matters | Not applicable – use separate before blocks or conditionals |
| Ordering relative to other hooks | Runs after before(:example) hooks but before the example body | Runs where declared, after all before(:example) hooks of outer groups | Explicit order: before(:suite) → before(:context) → before(:example) |
| Typical use case | Define data that may or may not be used, with memoization | Create records that the subject must query before running | Complex, multi‑step setup or ordering matters |
| Performance impact | Minimal – only executed when referenced | Can add overhead if many examples never use it | Depends on the work done; no memoization overhead |
| Risk of leaking state | None – memoized per example | None – memoized per example, but eager execution may affect ordering | Potential leakage if before(:context) or before(:all) used |
Trade‑Offs Explained
- Lazy let keeps examples fast and isolated. If the example never references the value, the block is never run, saving time. However, if the subject under test implicitly relies on the data (e.g., a model callback that queries the DB), the example will fail unless you use
let!or abeforeblock. - Eager let! guarantees the data exists before the example body, but it runs for every example in scope, even those that don't need it. This can inflate test runtime and mask ordering issues if the eager block has side effects that interact with other hooks.
- before gives you full control over the exact order and side effects. It is ideal for multi‑step setup that cannot be expressed declaratively with
let. The downside is that you lose the named, overridable helper pattern and must manage ordering manually.
Concrete Validation Strategy
Before refactoring an existing spec, validate that your chosen approach behaves as expected. Follow these steps on a test machine that has RSpec 3.12+ and a transactional fixture setup.
1. Test Memoization with let
# spec/support/memoization_spec.rb
RSpec.describe 'let memoization' do
counter = 0
let(:counter_incrementer) { counter += 1 }
it 'runs the block only once per example' do
expect(counter_incrementer).to eq(1)
expect(counter_incrementer).to eq(1)
expect(counter).to eq(1)
end
end
Run with bundle exec rspec spec/support/memoization_spec.rb. The counter should be 1, proving the block is memoized.
2. Verify eager execution with let!
# spec/support/let_bang_spec.rb
RSpec.describe 'let! eager execution' do
counter = 0
let!(:eager) { counter += 1 }
it 'does not reference the value' do
expect(counter).to eq(1)
end
end
Run the spec. Even though the example never uses eager, the counter will be 1, confirming the block ran before the example body.
3. Order‑independence check
Run the suite with --order random and a fixed seed to ensure no hidden ordering dependencies:
bundle exec rspec --order random --seed 12345
bundle exec rspec --order random --seed 67890
Both runs should produce the same results. If failures appear, review any let! or before(:context) that might be leaking state.
4. Performance measurement
Measure the time impact of unnecessary let! calls:
time bundle exec rspec --profile spec/large_spec.rb
# Then replace let! with let and re‑run
Compare the total time output. A significant reduction indicates that eager setup was excessive.
Practical Implementation Example
Suppose you test a service that counts active users. The service queries User.active.count. The active scope reads the users table. We need a record that exists before the service runs.
RSpec.describe UserCountService do
let!(:active_user) { FactoryBot.create(:user, active: true) }
let!(:inactive_user) { FactoryBot.create(:user, active: false) }
subject { described_class.new }
it 'returns the correct count' do
expect(subject.call).to eq(1)
end
end
Here let! is appropriate because the service queries the database at the start of the example. If the service did not query the DB until later, you could switch to lazy let and wrap the subject call in a before that triggers the data creation only when needed.
Limitations & Checklist
- Never rely on a
letvalue persisting across examples – memoization is per example. - Avoid
before(:context)orbefore(:all)when transactional fixtures are enabled; state can leak between examples. - When overriding
letin nested contexts, remember thatlet!blocks declared in outer contexts run before nestedletoverrides. - Profile your suite after refactoring to ensure no hidden performance regressions.
Conclusion
Adopt a disciplined approach: start with let, use let! sparingly for prerequisites that must exist before the example body, and reserve before for imperative, multi‑step setup or ordering constraints. Validate each change with the counter tests above and monitor runtime to keep the suite healthy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.