Why Choose `let` Over instance variables in RSpec

March 25, 2022

Repetitive setup code in tests makes it difficult to refactor the tests. It also distracts and shifts our focus from the business logic that we’re trying to test with the setup details. This post explains three ways to reduce the setup duplication in your tests.

Here’s a simple Rails model I was trying to test.

class Task < ApplicationRecord
  def toggle(task_completed:)
    update(completed: task_completed)
  end
  
  def complete
    update(completed: true)
  end
end

I added the two tests corresponding to two methods.

RSpec.describe Task, type: :model do
  it "toggles a task" do
    task = Task.create(description: "Do Something", completed: false)
    expect(task.completed).to be false

    task.toggle(task_completed: true)
    expect(task.completed).to be true
  end
  
  it "completes a task" do
    task = Task.create(description: "Do Something", completed: false)
    expect(task.completed).to be false

    task.complete
    expect(task.completed).to be true
  end
end

These tests run fine, but we’re repeating the task creation code in each method. We don’t want to create a task manually for each test. That would be tedious. This duplication makes it difficult to refactor the tests. It also distracts and shifts our focus from the business logic that we’re trying to test with the setup details.

Now, I found three different ways to reduce this duplication. Use a hook provided by RSpec, use a helper method, or use the let construct. Let’s inspect them one by one.

Use the before hook

before { @task = Task.create(description: "Do Something", completed: false) }

There are a few drawbacks of using instance variables:

  1. Ruby creates instance variables any time you reference them and initialize them to nil unless you explicitly initialize them. Thus, if you misspell @task to @tasky, Ruby returns a new instance variable @tasky which is nil, instead of failing right away. Hence the test fails with a confusing error message, or it can even lead to subtle bugs.
  2. RSpec calls this hook before each spec. This means specs that don’t need to use the instance variable still end up creating it. Typically, this is not a big problem, but if the setup takes a long time, it slows down your tests.
  3. To refactor your tests to use instance variables, you need to change all task occurrences with @task.

Use a helper method

def task
  @task ||= Task.create(description: "Do Something", completed: false)
end

Here, we only create the @task the first time a spec calls the task method. From then onwards, all specs use the instance variable. This approach solves all the above drawbacks. However, the caching doesn’t work if the right-hand operation returns a falsy value. The method will call that operation each time the method is invoked.

Use the let construct

let(:task) { Task.create(description: "Do Something", completed: false) }

let defines a memoized helper method. In plain English, it means that let is lazy-evaluated. RSpec only runs the let block the first time a test tries to call the task method. There’s no chance of misspelling task, as Ruby will throw a NameError.

However, it’s important to keep in mind that let won’t cache the result across all specs. Each spec gets its value from the execution of the block. For example, notice that we are starting from an incomplete task in the second spec, even if the first spec marked it complete.

RSpec.describe Task, type: :model do

  let(:task) { Task.create(description: "Do Something", completed: false) }

  it "toggles a task" do
    expect(tasky.completed).to be false

    task.toggle(task_completed: true)
    expect(task.completed).to be true # complete the task
  end

  it "completes a task" do
    expect(task.completed).to be false # task is incomplete, as it's a new task

    task.complete
    expect(task.completed).to be true
  end
end

I hope this post helped you understand different ways in which you can reduce the duplicated setup code in your tests. If you’re aware of any other approaches, or know any other pros and cons of the above ones, do let me know.