Search code examples
rspecperformance-testingrspec-railslet

How much time does "let" really save in RSpec tests?


I find it much easier to just set a variable in my code than use let. let is finicky and always tells me ways in which I'm using it wrong.

When I use a simple variable declaration in my specs like

tx_good = makeTransaction1() , everything works fine.

But when I use let like so

let(:tx_good) { makeTransaction1() } I will invariably get some error like this telling me it can't go here or there...

  `let` and `subject` declarations are not intended to be called
   in a `before(:context)` hook, as they exist to define state that
   is reset between each example, while `before(:context)` exists to
   define state that is shared across examples in an example group.

Given how finicky using let is, I'm forced to wonder is it worth the extra effort and care I must put forward to use it. Does anyone know how much processing time is really saved using let vs. merely assigning a variable up front?

I want to follow good testing protocol, so I'm hoping someone can convince me of why I should use let like (seemingly) everyone else does.


Solution

  • You're using this stuff wrong, and I understand your frustration. So let me give you a condensed manual for using lets in RSpec.

    Main value in using let does not come from saved processing power. It integral part of the wider RSpec philosophy. I'll try to explain and hopefully it'll be easier for you to progress...

    let is lazy

    whatever you define inside the block will be called if and only if it is actually used in the spec:

    context do
      let(:foo) { sleep(10000) } # will not happen
      specify { expect(1).to eq(1) }
    end 
    
    context do 
      specify do 
         foo = sleep(10000) # you'll wait
         expect(1).to eq(1)
      end
    end
    

    Use let!, which is eager (i.e. not lazy) version of let

    let is memoized

    Whatever is defined inside the block will happen only once (in the scope of the context):

    context do
      let(:random_number) { rand }
      specify do
        expect(random_number).to eq(random_number) # will always pass
      end
    end
    

    If you don't want this feature, define a method:

    context do
      def random_number
        rand
      end
      specify do
        expect(random_number).to eq(random_number) # sometimes pass, mostly fail
      end
    end
    

    let in lower level contexts overwrites let definitions from higher level:

    context do
       let(:x) { 1 }
       specify { expect(x).to eq(1) # pass
    
       context 'with different x' do 
         let(:x) { 2 }
         specify { expect(x).to eq(2) # pass
       end
    
       context do
         specify { expect(x).to eq(1) # pass
       end
    end
    

    ^ this allows you to compose the specs in a way, where only relevant "pieces" of the setup is mentioned in the context, for example:

    context do 
       let(:x) { 1 }
       let(:y) { 1 }
       let(:z) { 1 }
       specify { expect(foo(x, y, z)).to eq(3) }
    
       context 'when z is nil'
         let(:z) { nil }
         specify { expect(foo(x, y, z)).to raise_error) } # foo doesn't work with z = nil
       end
    
       context 'when x is nil'
         let(:x) { nil }
         specify { expect(foo(x, y, z)).to eq(15) } 
       end
    end
    

    Bonus: subject is a magic let

    # writing 
    subject { foo(x) }
    # is almost the same as writing 
    let(:subject) { foo(x) }
    

    subject is a reserved concept in RSpec, it's a "thing you test" so you could write the example with `foo(x, y, z) like this:

    context do 
       let(:x) { 1 }
       let(:y) { 1 }
       let(:z) { 1 }
       subject { foo(x, y, z) }
       specify { expect(subject).to eq(3) }
    
       context 'when z is nil'
         let(:z) { nil }
         specify { expect(subject).to raise_error) } # foo doesn't work with z = nil
       end
    
       context 'when x is nil'
         let(:x) { nil }
         specify { expect(foo(subject)).to eq(15) } 
       end
    end
    

    Regarding the error you have...

    let and subject declarations are not intended to be called in a before(:context) hook, as they exist to define state that is reset between each example, while before(:context) exists to
    define state that is shared across examples in an example group.

    you're doing something like

    before do
      let(:x) { ... }
    end
    

    just don't do it, you define let inside describe and context, but you can use them (not define them, use what is defined) inside before and specify:

    let(:name) { 'Frank' }
    before do
      User.create name: name
    end
    
    specify do
       expect(User.where(name: name).count).to eq(1)
    end