Why Choose `let` Over instance variables in RSpec
Tags: ruby, rails, rspec, testing
Published: 2022-04-07 00:57:45

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. 

```ruby
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. 

```ruby
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**

```ruby
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**

```ruby
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**

```ruby
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. 

```ruby
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.