The purpose of let and the differences between let and instance variables
RSpec’s let
helper method is a way of defining values that are used in tests. Below is a typical example.
require 'rspec'
RSpec.describe User do
let(:user) { User.new }
it 'does not have an id when first instantiated' do
expect(user.id).to be nil
end
end
Another common way of setting values is to use instance variables in a before
block like in the following example.
require 'rspec'
RSpec.describe User do
before { @user = User.new }
it 'does not have an id when first instantiated' do
expect(@user.id).to be nil
end
end
There are some differences between the let
approach and the instance variable approach, with one in particular that’s quite significant.
Differences between let and instance variables
First, there’s the stylistic difference. The syntax is of course a little different between the two approaches. Instance variables are of course prefixed with @
. Some people might prefer one syntax over the other. I personally find the let
syntax ever so slightly tidier.
There are also a couple mechanical differences. Because of how instance variables work in Ruby, you can use an undefined instance variable and Ruby won’t complain. This presents a slight danger. You could for example accidentally pass some undefined instance variable to a method, meaning you’d really be passing nil
as the argument. This means you might be testing something other than the behavior you meant to test. This danger is admittedly remote though. Nonetheless, the let
helper defined not an instance variable but a new method (specifically, a memoized method—we’ll see more on this shortly), meaning that if you typo your method’s name, Ruby will most certainly complain, which is of course good.
The other mechanical difference is that let
can create values that get evaluated lazily. I personally find this to be a dangerous and bad idea, which I’ll explain below, but it is a capability that the helper offers.
Perhaps the most important difference between let
and instance variables is that instance variables, when set in a before
block, can leak from one file to another. If for example an instance variable called @customer
is set in “File A”, then “File B” can reference @customer
and get the value that was set in File A. Obviously this is bad because we want our tests to be completely deterministic and independent of one another.
How let works and the difference between let and let!
How let works
I used to assume that let
simply defines a new variable for me to use. Upon closer inspection, I learned that let
is a method that returns a method. More specifically, let
returns a memoized method, a method that only gets run once.
Since that’s perhaps kind of mind-bending, let’s take a closer look at what exactly this means.
An example method
Consider this method that 1) prints something and then 2) returns a value.
def my_name
puts 'thinking about what my name is...'
'Jason Swett'
end
puts my_name
When we run puts my_name
, we see the string that gets printed (puts 'thinking about what my name is...'
) followed by the value that gets returned by the method (Jason Swett
).
$ ruby my_name.rb
thinking about what my name is...
Jason Swett
Now let’s take a look at some let
syntax that will create the same method.
require 'rspec'
describe 'my_name' do
let(:my_name) do
puts 'thinking about what my name is...'
'Jason Swett'
end
it 'returns my name' do
puts my_name
end
end
When we run this test file and invoke the my_name
method, the same exact thing happens: the method `puts`es some text and returns my name.
$ rspec my_name_spec.rb
thinking about what my name is...
Jason Swett
.
Finished in 0.00193 seconds (files took 0.08757 seconds to load)
1 example, 0 failures
Just to make it blatantly obvious and to prove that my_name
is indeed a method call and not a variable reference, here’s a version of this file with parentheses after the method call.
require 'rspec'
describe 'my_name' do
let(:my_name) do
puts 'thinking about what my name is...'
'Jason Swett'
end
it 'returns my name' do
puts my_name() # this explicitly shows that my_name() is a method call
end
end
Memoization
Here’s a version of the test that calls my_name
twice. Even though the method gets called twice, it only actually gets evaluated once.
require 'rspec'
describe 'my_name' do
let(:my_name) do
puts 'thinking about what my name is...'
'Jason Swett'
end
it 'returns my name' do
puts my_name
puts my_name
end
end
If we run this test, we can see that the return value of my_name
gets printed twice and the thinking about what my name is...
part only gets printed once.
$ rspec my_name_spec.rb
thinking about what my name is...
Jason Swett
Jason Swett
.
Finished in 0.002 seconds (files took 0.08838 seconds to load)
1 example, 0 failures
The lazy evaluation of let vs. the immediate evaluation of let!
When we use let
, the code inside our block gets evaluated lazily. In other words, none of the code inside the block gets evaluated until we actually call the method created by our let
block.
Take a look at the following example.
require 'rspec'
describe 'let' do
let(:message) do
puts 'let block is running'
'VALUE'
end
it 'does stuff' do
puts 'start of example'
puts message
puts 'end of example'
end
end
When we run this, we’ll see start of example
first because the code inside our let
block doesn’t get evaluated until we call the message
method.
$ rspec let_example_spec.rb
start of example
let block is running
VALUE
end of example
.
Finished in 0.00233 seconds (files took 0.09836 seconds to load)
1 example, 0 failures
The “bang” version of let
, let!
, evaluates the contents of our block immediately, without waiting for the method to get called.
require 'rspec'
describe 'let!' do
let!(:message) do
puts 'let block is running'
'VALUE'
end
it 'does stuff' do
puts 'start of example'
puts message
puts 'end of example'
end
end
When we run this version, we see let block is running
appearing before start of example
.
$ rspec let_example_spec.rb
let block is running
start of example
VALUE
end of example
.
Finished in 0.00224 seconds (files took 0.09131 seconds to load)
1 example, 0 failures
I always use let!
instead of let
. I’ve never encountered a situation where the lazily-evaluated version would be helpful but I have encountered situations where the lazily-evaluated version would be subtly confusing (e.g. a let
block is saving a record to the database but it’s not abundantly clear exactly at what point in the execution sequence the record gets saved). Perhaps there’s some performance benefit to allowing the lazy evaluation but in most cases it’s probably negligible. Confusion is often more expensive than slowness anyway.
Takeaways
- The biggest advantage to using
let
over instance variables is that instance variables can leak from test to test, which isn’t true oflet
. - The difference between
let
andlet!
is that the former is lazily evaluated while the latter is immediately evaluated. - I always use the
let!
version because I find the execution path to be more easily understandable.