My issues with Let

Jason Draper

For a while now, we’ve recommended against using let in RSpec and minitest. We even have a blog post explaining our thoughts. Despite that, I still have trouble explaining my problems with let and before to other developers. They’re wonderful for cleaning up duplication, and can make your tests smaller. That has to be a win, right?

Maybe not.

Let’s talk about some of the pain associated with using these options.

Let’s hide some things

Constructs like let allow us to hide our code. This may seem like a great thing when you initially start using it but in the end, it can lead to a lot of pain. Here’s an example from a code base I recently worked on:

it "application_header must be empty" do
  application_header.programs(true).must_be_empty
  ApplicationHeader.count.must_equal 0
end

Without any context, this seems like a fairly reasonable test. This test alone was taking almost an entire minute to execute! What you don’t see in this test is that the let statement for application_header was referencing 18 other let statements, inserting 23 database records and running 25 additional queries. WHOA!

My mind was blown so I did a bit of digging in the Git history. When this test was originally written, it only executed 2 queries. The author did a great job of testing exactly what needed to be tested. In the time since that test was written, other developers needed other things to be tested alongside the application_header and so they added a query here or an association there. The final result was a single test that ended up taking a full minute.

Cascading creation

let!(:person) {
  create :person,
  gender: female,
  date_of_birth: date_of_birth,
  city_of_birth: city,
  state_of_birth: state,
  country_of_birth: country,
  county_of_birth: county,
  number_of_dependents: dependents,
  nickname: nickname,
  materials_under_another_name: materials,
  alt_first_name: alt_first,
  alt_middle_name: alt_middle,
  alt_last_name: alt_last,
}

let(:person_presenter) {
  PersonPresenter.new(person)
}

describe '#person_presenter' do
  specify { person_presenter('birth_location').must_include city.name }
end

Again we see a test that, on the surface, looks reasonable. The thing you don’t see is that every reference (like country) in the person block is to another let statement. Each of those let statements is at least a single database call, some of them have multiple. The end result is that this test, and all the other tests in the file, start out with a 7 second overhead.

Let’s see what we can do to speed that up:

describe '#person_presenter' do
  it "shows the birth location as the city" do
    city_name = "Raleigh"
    city = build_stubbed(:city, name: city_name)
    person = build_stubbed(:person, city_of_birth: city)

    presenter = PersonPresenter.new(person)

    presenter("birth_location").must_include city_name
  end
end

This test does not have any external references and does not include a before block. It runs in under a second. Even more importantly, it’s easy to read. You can clearly see each step that is required and each external dependency. This test is also nice because it doesn’t hit the database at all.

Is let the problem?

I don’t think that let is inherently bad but it does make it easier for your tests to grow out of control. It allows you to move the important parts of your test out of your test. It makes it unclear what is really happening and can lead to bloat. By putting your setup inline, you ensure that each test has exactly what is required to work with the test.

Don’t think of your test files as a class that needs to share information and reduce duplication. Think of each individual test as its own class and if you need to pull out shared functionality, do so carefully.