Why I don’t use let/let! in my RSpec

At work, we’re deciding on our test-writing style: let/let! blocks like let(:arg) { 5 } vs. instance variables defined in a setup method like @arg = 5.

  • I’ve found no advantage to let; but I have experienced disadvantages.
  • I’ve found no disadvantages to instance variables.

And so, 👍 for instance variables.


I’ve written many specs and have read the rspec docs, betterspecs.org many times.

I don’t use let/let! because

  • the purported advantage of lazy evaluation is never actually realized. I’m most always running all the tests in a file, and so there’s no time efficiency gain;
  • the API [let and let!] and their magic increase the code’s complexity and so must be balanced out by some other advantage;
  • Let introduces magic and apparently nondeterministic behavior which has broken my tests, and I’ve only been able to fix by converting to easy-to-understand @- variable instantiations.
  • Let has the problem of introducing non-ruby syntax — something that looks like an automatic variable isn’t one anymore.

So for me, let is fixing a problem I don’t have, and in doing so, introduces new problems.

18 thoughts on “Why I don’t use let/let! in my RSpec

  1. The ability to define a let in an outer context that relies on a let defined in a nested context can be advantageous by deferring pieces of test setup into the context that is describing the relevant behavior; and, this capability is a feature provided by lazy-evaluation. Additionally, let lazy evaluation behavior is not, to my knowledge, intended as a performance-oriented feature — instead, because the value is memoized and cleaned up automatically for each example, it provides a reliable way to manage test state. The alternative is before(:each) with instance variables, which is complicated by any shared behaviors, which can interfere with instance variables from within their own context. With shared behaviors, let reveals another advantage because a block can be passed to a shared behavior containing definition of one or more let calls that provide state isolation for the given shared behavior.

    In the end, the built-in management of let state is the key advantage, and this stands in contrast to the need to think deliberately about the management of instance variables in the context of RSpec examples.

    Liked by 5 people

    1. I agree with James Thompson, I couldn’t have described this any better:

      “The ability to define a let in an outer context that relies on a let defined in a nested context can be advantageous by deferring pieces of test setup into the context that is describing the relevant behavior; and, this capability is a feature provided by lazy-evaluation”

      Sounds like you may not be making use of the potential of let itself.

      Liked by 1 person

  2. Exactly what ^^ James Thompson said. Our specs tend to have a structure like

    describe MyClass do
      subject { described_class.new(arg: arg) }
      let(:arg) { 42 }
    
      describe "#my_method" do
        subject { super().my_method }
    
        context "when arg is nil" do
          let(:arg) { nil }
          it { is_expected.to be_nil }
        end
      end
    end
    

    Does this make sense? RSpec’s let and subject laziness, and the ability to call super(), is what enables the nested scopes to define themselves concisely in terms of differences from the outer scopes, and makes the definition of contexts so concise and clear.

    Like

    1. Thanks for the code sample. I find it confusing: what object is super() being invoked upon? It’s too magical & implicit for my tastes. I like for my tests to read like documentation; a recipe for how to use the code.

      Like

      1. It’s actually super simple: super() works for both let and subject and refers to the same item in the parent context.

        Like

      2. See, now you’re losing my vote. 🙂 When I see an unqualified .super(), I “know” that to mean self.super() in Ruby. And so my inquiry as a Rubyist is, “What is self in this context?” I look for the enclosing class. In an RSpec example, it’s not clear at all what the enclosing class is. I’d first assume maybe some kind of RSpec example instance.

        But you’re saying that super() is part of the let framework? More unpleasant magic, IMO, and violation of Principle of Least Surprise.

        Like

  3. Here is a reason to use let:

    I’ve made the same mistake in the following two specs using your instance variable style and a normal let style:

    $ cat example1_spec.rb
    require_relative 'user'
    
    RSpec.describe 'example #1' do
      before do
        @validation = User.new.valid?
      end
    
      it 'is not valid' do
        expect(@vlaidation).to be_falsey
      end
    end
    $ cat example2_spec.rb
    require_relative 'user'
    
    RSpec.describe 'example #1' do
      let(:validation) { User.new.valid? }
    
      it 'is not valid' do
        expect(vlaidation).to be_falsey
      end
    end
    

    Maybe you see the bug. For each style, do our specs see the bug?

    $ rspec example1_spec.rb
    .
    
    Finished in 0.00146 seconds (files took 0.07895 seconds to load)
    1 example, 0 failures
    
    $ rspec example2_spec.rb
    F
    
    Failures:
    
      1) example #1 is not valid
         Failure/Error: expect(vlaidation).to be_falsey
    
         NameError:
           undefined local variable or method `vlaidation' for #<RSpec::ExampleGroups::Example1:0x007f80f0153830>
           Did you mean?  validation
         # ./example2_spec.rb:7:in `block (2 levels) in <top (required)>'
    
    Finished in 0.00046 seconds (files took 0.08199 seconds to load)
    1 example, 1 failure
    
    Failed examples:
    
    rspec ./example2_spec.rb:6 # example #1 is not valid
    

    Undefined instance variables falling back to nil is an unfortunate design decision we have to live with and anticipate in ruby. I usually try to avoid referencing them directly when possible.

    Liked by 1 person

    1. Sweet. That’s a good reason I hadn’t thought of. Makes me never want to use instance variables. I wonder if constants could be used instead.

      Like

      1. You could use constants in theory but it is going to spit out a lot of warnings. Also, unless you do something like described_class::FOO = 1, you would be setting a top level constant which could cause a lot of weirdness and is pretty much equivalent to just using global variables. Even if you did do described_class::FOO = 1 too you would still have to be very careful because, if any of your specs don’t describe a constant (so if they do RSpec.describe "Foo") thendescribed_class::FOO = 1becomesnil::FOO = 1which is just::FOO = 1` and you have the same problem all over again.

        Like

  4. Hi Robb! I agree with you, let can cause many headaches. This is one of my favorite blog posts on the topic: https://robots.thoughtbot.com/lets-not

    Where I disagree with you is your endorsement of instance variables and before statements. I do not use instance variables or before or subject in my specs. I rely on plain old variables and methods to get the job done.

    I’ve heard Derek Prior, co-host of the Bike Shed (podcast) say something to the effect of “I’ve noticed that the closer my RSpec tests are to the way I write regular Ruby, the better.” (Sorry to Derek if I am misremembering the phrasing…but it was something like that)

    To show why I think my style is clearer, I will re-write the example from above how I would do it:

    Example:

    require_relative 'user'
    
    RSpec.describe 'example #1' do
      before do
        @validation = User.new.valid?
      end
    
      it 'is not valid' do
        expect(@vlaidation).to be_falsey
      end
    end
    

    Jessie Example:

    require_relative 'user'
    
    RSpec.describe 'example #1' do
      it 'is not valid' do
        validation = User.new.valid?
        expect(validation).to be_falsey
      end
    end
    

    I find that second example to be much easier to read. And the readability benefits increase 10x when there are many specs in a file (as there usually are) and the setup for each test is contained within each it block. When there is a lot of shared setup, I will throw that in a private method in the file and call the method so it is still obvious what is happening to the spec reader.

    Liked by 1 person

    1. Interesting that you define a private method to dry up significant duplication. let methods are just defining methods with a bit of memoization baked in so extracting a method is roughly equivalent I think to using let. With that said, I think an ideal style starts with what you’re describing here. Inline everything into your tests and then pull out duplication once it is making it slower to evolve your tests (this comes down to personal taste).

      Like

  5. IMHO subject and let are excellent tools.
    subject is a obvious way to show the object under test and let is also a way to show collaborators explicitly.

    I agree that having nested lets is a bad practice though.

    I have been following http://betterspecs.org/ for some years and I really like it.
    A nice thing about their website is that they provide github issues to discuss the good practices. Something that is harder in a blog post.

    Nice to hear you opinion.

    Like

Comments are closed.