Introduction
RSpec's let! eagerly evaluates and caches values in before blocks, while let lazily evaluates on first access. When tests depend on the evaluation order of let! blocks, they become flaky: passing when run alone but failing when run as part of a suite. The order of let! evaluation follows their textual order in the spec file, but this order changes when shared examples, inherited contexts, or randomization affect execution.
Symptoms
- Test passes with
rspec spec/path/to/file_spec.rbbut fails withrspec spec/ - Test passes when run individually but fails in random order
--order definedpasses but--order randomfailslet!evaluation creates database records that interfere with other tests- Shared examples cause unexpected
let!evaluation timing
Detect flaky tests: ```bash # Run tests in reverse order rspec spec/ --order reverse
# Run 100 times with different seeds for i in {1..100}; do rspec spec/path/to/flaky_spec.rb --seed $RANDOM || echo "Failed with seed $RANDOM" done ```
Common Causes
let!creating records with overlapping unique constraintslet!depending on anotherlet!being evaluated first- Shared examples introducing
let!blocks that change evaluation order beforeblocks andlet!mixing side effects- Database cleaner not isolating state between examples
Step-by-Step Fix
- 1.Replace order-dependent let! with explicit before blocks:
- 2.```ruby
- 3.# FLAKY - depends on let! evaluation order
- 4.describe "user dashboard" do
- 5.let!(:user) { create(:user) }
- 6.let!(:posts) { create_list(:post, 3, user: user) } # depends on user
- 7.let!(:comments) { create_list(:comment, 5, post: posts.first) } # depends on posts
it "shows all content" do expect(page).to have_content(posts.first.title) end end
# RELIABLE - explicit before block with clear ordering describe "user dashboard" do let(:user) { create(:user) } let(:posts) { create_list(:post, 3, user: user) } let(:comments) { create_list(:comment, 5, post: posts.first) }
before do # Explicit ordering - no ambiguity user posts comments end
it "shows all content" do visit dashboard_path expect(page).to have_content(posts.first.title) end end ```
- 1.Fix shared examples that introduce let!:
- 2.```ruby
- 3.# PROBLEMATIC - shared example adds let! that may evaluate out of order
- 4.shared_examples "a deletable resource" do
- 5.let!(:resource) { create(described_class.name.underscore.to_sym) }
it "can be deleted" do resource.destroy expect(described_class.count).to eq(0) end end
# FIXED - shared example uses lazy let and explicit setup shared_examples "a deletable resource" do let(:resource) { create(described_class.name.underscore.to_sym) }
it "can be deleted" do # Force evaluation resource resource.destroy expect(described_class.count).to eq(0) end end ```
- 1.Use database cleaner to isolate test state:
- 2.```ruby
- 3.# spec/support/database_cleaner.rb
- 4.RSpec.configure do |config|
- 5.config.before(:suite) do
- 6.DatabaseCleaner.clean_with(:truncation)
- 7.end
config.before(:each) do DatabaseCleaner.strategy = :transaction end
config.before(:each, js: true) do DatabaseCleaner.strategy = :truncation end
config.before(:each) do DatabaseCleaner.start end
config.after(:each) do DatabaseCleaner.clean end end ```
- 1.**Add flaky test detection to CI":
- 2.```yaml
- 3.# .github/workflows/ci.yml
- 4.- name: Run tests with multiple seeds
- 5.run: |
- 6.for seed in 12345 54321 99999; do
- 7.echo "Running with seed $seed"
- 8.bundle exec rspec spec/ --order random:$seed
- 9.done
# Or use the rspec-retry gem gem 'rspec-retry', group: :test
# spec/spec_helper.rb RSpec.configure do |config| config.verbose_retry = true config.display_try_count_messages = true config.default_retry_count = ENV["CI"] ? 2 : 0 end ```
- 1.Debug let! evaluation order:
- 2.```ruby
- 3.# Add to spec_helper to trace let! evaluation
- 4.module LetTrace
- 5.def let!(name, &block)
- 6.let(name) do
- 7.puts "EVALUATING let!(:#{name}) in #{self.class.description}"
- 8.instance_exec(&block)
- 9.end
- 10.end
- 11.end
RSpec.configure do |config| config.extend LetTrace end ```
Prevention
- Prefer
let(lazy) overlet!(eager) unless eager evaluation is required - Use
beforeblocks for setup that has ordering requirements - Run full test suite with
--order randomin CI on every commit - Avoid shared examples that create database records via
let! - Keep examples self-contained with their own data setup
- Use
RSpec::FlakyTestsgem to automatically detect and retry flaky tests