Skip to main content

Command Palette

Search for a command to run...

RSpec and `let!`: Understanding the Potential Pitfalls

Updated
4 min read
RSpec and `let!`: Understanding the Potential Pitfalls

This is not a new topic; various resources have addressed it in different ways. Here are my reasons and explanations for why I prefer not to use 'let!' in RSpec.

When I work on a project that uses RSpec, I prefer not to use let!. Instead, I call the let variable inside the before block.

RSpec.describe Thing do   
 let(:precondition) { create(:item) }

 before  
   precondition  
 end

 it 'returns that specific value' do  
   # do
   # expect  
 end  
end

Taking it a step further, if you do not need to reference precondition in your tests, you can do this instead:

RSpec.describe Thing do   
 before  
   create(:item)  
 end

 it 'returns that specific value' do  
   # do
   # expect  
 end  
end

First what does let! do? It just a call to the normal let and then setting a before block for the same name.

# source: https://github.com/rspec/rspec/blob/main/rspec-core/lib/rspec/core/memoized_helpers.rb#L329
def let!(name, &block)
  let(name, &block)
  before { __send__(name) }
end

Reason 1: let! is actually a precondition for a test and it hides it

In testing let! acts as a test precondition: the required state of the system before running a specific test. This should be visible while reading the test and using ! for this makes it hard to spot and hard to load it in the mental context.

It is harder to notice a let! among several let statements when you are trying to debug than seeing a before block which clearly communicate intention to run some preconditions.

RSpec.describe Thing do   
 let(:account) { build(:account) }  
 let!(:organisation) { build(:organisation, account: account) }

 it 'returns that specific value that we want' do  
   # test  
 end  
end

compared with:

RSpec.describe Thing do
 let(:account) { build(:account) }  
 let(:organisation) { build(:organisation, account: account) }

 before
   organisation  
 end

 it 'returns that specific value that we want' do  
    # test 
 end  
end

Which version makes it clearer that the organisation is created before each test?

Reason 2: It hides the order of execution of preconditions

Here is a comparison:

RSpec.describe Thing do   
 let!(:account_a) { build(:user, email: email) }  
 let!(:account_b) { build(:user, user: email2) }  
 let(:organisation) { build(:organisation, account: account) }  
 let(:team) { build(:team, account: organisation) }  

 it 'returns that specific value that we want' do  
    # test 
 end  
end

compared to

RSpec.describe Thing do   
 let(:account_a) { build(:user, email: email) }  
 let(:account_b) { build(:user, user: email2) }  
 let(:organisation) { build(:organisation, account: account) }  
 let(:team) { build(:team, account: organisation) }  

 before  
   account_a  
   account_b  
 end

 it 'returns that specific value that we want' do  
    # test 
 end  
end

But imagine that after a series of changes there is a risk that someone might put a let! among other calls.

RSpec.describe Thing do   
 let!(:account_a) { build(:user, email: email) }  
 let(:organisation) { build(:organisation, account: account) }  
 let!(:account_b) { build(:user, user: email2) }
 let(:team) { build(:team, account: organisation) }  

 it 'returns that specific value that we want' do  
    # test 
 end  
end

A Minitest equivalent

Here are three ways to write something similar in Minitest:

class ThingTest < Minitest::Test  
   def setup  
       @account_a = build(:user, email: email)  
       @account_b = build(:account, user: user)  
       @organisation =  build(:organisation, account: account)  
   end

   def test_computed_slug_returns_with_dashes  
       @account_a.name = "My Name"        

       assert_equal "my-name", @account_a.computed_slug  
   end  
end

Here is another approach:

class ThingTest < Minitest::Test  
   attr_accessor :account_a, :account_b, :organisation  

   def setup  
       @account_a = build(:user, email: email)  
       @account_b = build(:account, user: user)  
       @organisation =  build(:organisation, account: account)  
   end

   def test_computed_slug_returns_with_dashes  
       account_a.name = "My Name"

       assert_equal "my-name", account_a.computed_slug  
   end  
end

👉 If you like this article and want it in your inbox each week, subscribe to my newsletter. You’ll find ideas on Ruby, software development, software testing, building products and workshops, plus notes on creativity, tech trends, and whatever else sparks my curiosity.

👐 Want to improve your developer testing skills? Visit goodenoughtesting.com/articles to discover resources on testing for developers.

👉 Join my Short Ruby Newsletter for weekly Ruby updates and visit rubyandrails.info, a directory of Ruby learning content.

🤝 Connect with me on Linkedin, Bluesky, Ruby.social, , and Twitter, where I mostly post about Ruby and Ruby on Rails.

🎥 Follow my YouTube channel for short videos about Ruby and Rails.

More from this blog

All about code - Ruby and Rails technical content written by Lucian Ghinda

102 posts

I write here quick thoughts, ideas, tips, and learnings about programming, programmers, and building software. Most of my focus is on Ruby, Rails, Hotwire, and everything about web applications.