The difference between let, let! and instance variables in RSpec

by Jason Swett,

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 of let.
  • The difference between let and let! 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.
  •  
  •  
  •  
  • 47

11 thoughts on “The difference between let, let! and instance variables in RSpec

  1. Kelly Hong

    Thanks so much for the clear explanations! All this time I thought let was creating variables. I’d be curious to know what situations you have encountered where the lazily-evaluated version would be subtly confusing. That was kind of a cliffhanger at the end of the article. Also I thought the article would touch on let scope but maybe that’s common knowledge.

    Reply
    1. Jason Swett Post author

      Thanks! You raise a good point. I’ve edited that part to include an example. I’ve had situations before where a let block saves a database record, and it superficially looks like the record gets saved before the tests get executed, but due to let’s lazy evaluation the record actually doesn’t exist until after the time someone might expect the record to get created, resulting in a test that behaves mysteriously differently from what’s expected.

      Reply
  2. Francesco

    I’m conflicted on the value of `let!` (`let` is possibly an anti-pattern given that it hides the execution path entirely).

    A before block using instance variables achieves the same thing, with normal Ruby flow that is better known than `let!`.

    Given that, I question the value of its existence. It seems to solve a problem created by `let` itself

    Reply
  3. João Paulo lethier

    I think that is something that is not clear for me in the posts, and a little confusing with the definition that let is not defining variables, but methods. The “method” that let is defining is executed only once, and in the subsequent calls for the let “variable”, the method is not executed anymore. So, it appears that let is not defining a method too, but maybe something in between the definition of a method and a variable, where the method is executed for the first time the let is called, and for the subsequent calls, the variable is accessed directly. Is that right?

    Reply
    1. Daniel

      Yep. “let” defines a memoized method where it runs once the first time it’s executed, and then returns the value of that execution subsequently.

      Reply
  4. Daniel

    One major difference is that both “let” and “let!” have their values automatically reset for every execution. Instance variables can leak between tests, which is the major reason they are discouraged.

    And yes, the main reason “let” is lazy-evaluated is for performance. It’s particularly useful if you want to define some high-level *possible* values that you might use in a number of your tests but might not use in others. Especially in larger Rails projects with hundreds or thousands of tests, the difference between the two can be anywhere from seconds to minutes for a test suite to run.

    Reply
  5. schmijos

    `let!(…) { … }` is actually identical to `before { let(…){ … } }`. So I agree that from your view the control flow is clearer than using `let`. But this is not the angle I look upon the matter because the main (and here missing) advantage is the possibility to DRY up your specs by overriding your `let`s together with `context` like this:

    “`
    describe ‘request XY’ do
    before do
    perform_something(params: { id: id })
    end

    context ‘when id is NOT specified’ do
    let(:id) { nil }

    it { is_expected.to be_successful }
    end

    context ‘when id is out of range’ do
    let(:id) { 1_000_000 }

    it { is_expected.not_to be_successful }
    end

    context ‘when id valid’ do
    let(:id) { create(:dbobject).id }

    it { is_expected.to be_successful }
    end
    end
    “`

    You need to get used to this inverted style of reading your tests, but it allows you to reduce the lines of codes immensely (normally for one cross-cutting concern, but with shared examples and contexts for more than one).

    If you agree upon this advantage of `let`, it starts getting tricky with `let!` and I’d suggest you to not “always use the `let!`”. Because you will maneuver yourself into situations where you’re tempted to write code like this:

    “`
    let!(:logged_in_user) { create(:user) }

    … do your tests with the logged-in user

    context ‘when the user is NOT logged-in’ do
    let(:logged_in_user) { nil }

    end
    “`

    As soon as this happens, your code is doomed. It’s very difficult for a reader to distinguish between `let!` and `let` if he/she needs to debug the test here. In such a case I’d rather use `before` to make it very clear that `logged_in_user` is ALWAYS and in every case initialized.

    Reply
    1. Francesco

      What’s the value in doing that?
      What about putting the before block in each context (yes, repeating the same line)?
      Source code is written for developers, not for machines. Sacrificing readability is effectively taking value away from the consumer of the tests, which is the developer, not the machine.

      If the abstraction is more convoluted than that (multiple inter-dependent-let), then the readability is affected more, to confuse the consumer of the tests even more. In that case, a better alternative is to write a substitute: a real object, fully tested like normal production code, with diagnostic capabilities and all affordances needed to make writing tests a more comfortable process.
      In this case, the consumer of the tests as well as the writer of the tests benefit from it, effectively doubling the value and improving readability at the same time.

      My input with using always let! is so that you never end up doing the let-replacement, on purpose.

      The alternative approach where your eyes have to jump back-and-forth across the file is a well known pattern, it’s what happened when code was written using goto.

      Reply

Leave a Reply

Your email address will not be published. Required fields are marked *