All about RSpec let

This post is all about RSpec’s let method. What it’s for, when you should use it and when you should avoid it.

What does let do?

let generates a method whose return value is memoized after the first call. This is known as lazy loading because the value is not loaded into memory until the method is called. Here is an example of how let is used within an RSpec test. let will generate a method called thing which returns a new instance of Thing. You can use it the same way that you would call a normal Ruby method.

describe Thing do
  let(:thing) { Thing.new }

  it "does something" do
    thing.do_something
  end
end

Instead of using a let you could write your test using regular instance variables.

describe Thing do
  it "does something" do
    @thing = Thing.new
    @thing.do_something
  end
end

The problem with this is that you can’t share @thing between other it blocks. You could solve this problem using a before block.

describe Thing do
  before do
    @thing = Thing.new
  end

  it "does something" do
    @thing.do_something
  end

  it "does something else" do
    @thing.do_something_else
  end
end

However, using a before block is not equivalent to using a let. A before block will set up state when you initially run the test and share that state between all of your tests. When you use let the state is reset for every it block and the value is only evaluated if it is called from within the current it block.

Why use let?

We’ve established that standard variable declaration, before blocks and let all have different characteristics. So what are the advantages of using let?

  1. let helps to DRY up your tests. You can share variables between all of your examples and you can also override them in specific tests. In this example, all of your tests can use thing. You can also override thing to a different value, in a describe block.
    describe Thing do
      let(:thing) { Thing.new }
    
      it "does something" do
        thing.do_something
      end
    
      describe "different case" do
        let(:thing) { Thing.new(case: "different") }
    
        it "does something else" do
          thing.do_something_else
        end
      end
    end
    
  2. Lazily evaluated. before blocks can make your tests slow when they set up a lot of state because the whole before block gets called even when running a single test. If you use let, instead, then it would only set up the state required for the specific test that you’re running.
  3. let can make your tests easier to read for simple examples. If you look at the examples above, you’ll probably agree that the test implementation that uses let is simpler, contains less code and is easier to read than the before block alternative.

The problem with let

Using let usually turns out well with small spec files. Problems start when your spec files become large. When you use a let in the top level describe block it becomes global to all of your tests. If you have a large spec file it can be difficult to know what is in scope because a let can be far away from the code that you’re looking at.

I’ve seen people abusing let, particularly in controller tests. One example is defining some params at the top of the spec file and then using merge! to modify params in individual tests. For example:

describe Thing do
  let(:params) do 
    { 
      id: 1,
      name: 'tom',
      published: true
    }
  end

  it "does something" do
    thing.do_something
  end

  describe "different case" do
    it "does something else" do
      params.merge!(published: false)
      thing.do_something_else
    end
  end
end

If this pattern is repeated multiple times within the spec file it makes it really difficult to know what params is referencing.

Should you use let?

RSpec’s documentation for let actually has a warning about overusing let:

let can enhance readability when used sparingly (1,2, or maybe 3 declarations) in any given example group, but that can quickly degrade with overuse.

I completely agree with this warning and I think that RSpec users should bear this in mind. You should use let but you should use it judiciously.