DEV Community

Akshay Khot
Akshay Khot

Posted on • Originally published at akshaykhot.com

Why Choose `let` Over instance variables in RSpec

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (5)

Collapse
 
dylanlacey profile image
Dylan Lacey

I've always liked this, too! It's one of the reasons I find other testing tools so ... unpleasant.

Especially when there's lots of syntactical punctuation littered around.

Collapse
 
gumatias profile image
Gustavo Matias

Curious on how you think about describe vs context. In simple terms, how you decide to use one vs the other in different scenarios?

Collapse
 
software_writer profile image
Akshay Khot

Definitely! I like how the tests can be self-documenting and also that RSpec provides a way to print the outcomes in various formats so it reads like a specification document.