All about coding

Ruby and Rails technical content written by Lucian Ghinda

RSpec and `let!`: Understanding the Potential Pitfalls

Markdown version

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

👐 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.