Deep Dive into RSpec's let Helper
Tags: rspec, ruby, testing, rails
Published: 2023-07-13 04:19:03

## Introduction

RSpec provides developers with a wide array of tools to write effective and robust tests. Among its many powerful features, the `let` helper quietly stands as a versatile tool for creating reusable variables within test examples. 

The `let` helper allows developers to define variables within test examples, memoizing their values for efficient reuse. While `let` may not appear as attention-grabbing as other RSpec features, its clever usage can significantly improve test readability, maintainability, and execution speed.

In this article, we will dig deep to explore the potential of the `let` helper and it's friendly neighbors `let!` and `subject`. We will unravel its capabilities for creating reusable variables and optimizing a test code.

## Understanding `let` Blocks in RSpec

Before delving into more depth techniques, let's talk about the nature of `let` block and why it's so useful. `let` block has 3 features which makes it a great tool:

1. `let` can be redefined

    In RSpec, the `let` keyword allows you to define variables that can be redefined within nested contexts. This means that you can override the initial definition of a `let` variable within a more specific context, providing flexibility in setting different values for the same variable in different scenarios.

2. `let` is memoized

    `let` block value is computed only once and then cached for subsequent references within the same example or context. This memoization ensures that the value of a `let` variable remains consistent throughout the example or context, even if it is referenced multiple times.

3. `let` is lazy evaluated

    `let` block value is not computed until the first time it is accessed within a specific example or context. This lazy evaluation improves performance by avoiding unnecessary computations for `let` variables that might not be used in a particular test scenario.

## Using `super()` within `let` blocks

The `super()` keyword in Ruby is typically used to call a method of the same name in the superclass. In the context of RSpec's `let` blocks, `super()` enables us to inherit and extend values defined in the parent context, providing a powerful mechanism for code reuse.

Let's consider an example where we have a `ShoppingCartItem` class and want to test its pricing functionality. The code snippet below demonstrates how we can utilize `super()` within `let` blocks to achieve this:

```ruby
RSpec.describe ShoppingCartItem do
  subject(:item) { described_class.new(item_params) }
  let(:item_params) { { name: 'Item', price: 10 } }

  it 'returns a full price' do
    expect(item.price).to eq(10)
  end

  context 'with a discount' do
    let(:item_params) { super().merge(discount: 2) }

    it 'returns a discounted price' do
      expect(item.price).to eq(8)
    end
  end
end
```

In this scenario, we have a `ShoppingCartItem` spec with a `subject`, item, representing an instance of `ShoppingCartItem` initialized with `item_params`. The `item_params` `let` block defines the default parameters for the item, including the name and price.

Within the nested context, `'with a discount'`, we redefine `item_params` using `super()`, which inherits and extends the original `item_params` from the parent context. By merging a `discount` key with a value of `2`, we modify the parameters to include a discount.

The subsequent test case within the nested context verifies that the item's price, accessed through `item.price`, correctly reflects the discounted price of `8`.

Benefits of `super()` in `let` blocks:

* Code Reusability. By leveraging `super()` in `let` blocks, we can avoid duplicating variable definitions in nested contexts. Instead, we inherit and extend variables from parent contexts, promoting code reuse and reducing redundancy.

* Flexibility and Dynamism. `super()` enables us to dynamically modify variables based on specific contexts or scenarios. This flexibility allows us to create adaptable tests that can handle varying scenarios without the need for redundant code.

* Maintainability. Using `super()` within `let` blocks improves the maintainability of tests by centralizing variable definitions in parent contexts. Modifying the inherited variable in a single location ensures consistency and reduces the likelihood of errors caused by inconsistent values.

### Understanding the impact of `super()` in the `subject`

At first glance, using `super()` in `subject` seems straightforward, but what if we introduce named subjects? Let's explore the behavior when you define a named subject, such as `subject(:value) { 'foo' }`, and then call `let(:value) { super() }` within a nested context. If this scenario leaves you unsure, read on as we delve into the complexities.

To better understand the implications of combining these helpers, I conducted several experiments with `super()` in different scenarios. Here's a handy cheat-sheet presented in RSpec code:

```ruby
describe "super() in `subject` or `let` blocks" do
  subject(:value) { 'subject(:value)' }

  context 'with `super()` in `let` with subjects name' do
    let(:value) { "#{super()} + let(:value)" }

    it 'appends `let` content to named variable' do
      expect(value).to eq('subject(:value) + let(:value)')
    end

    it 'keeps subject unchanged' do
      expect(subject).to eq('subject(:value)')
    end
  end

  context 'with `super()` in named subject' do
    subject(:value) { "#{super()} same" }

    it 'does not allow calling super in named subject' do
      expect { subject }.to raise_error(
        '`super` in named subjects is not supported'
      )
    end
  end

  context 'when calling `super()` in unnamed subject' do
    subject { "#{super()} + subject" }

    it 'appends content to previously defined subject' do
      expect(subject).to eq('subject(:value) + subject')
    end

    it 'keeps named variable unchanged' do
      expect(value).to eq('subject(:value)')
    end
  end

  context 'with `super()` in unnamed subject and `let`' do
    subject { "#{super()} + subject" }
    let(:value) { "#{super()} + let(:value)" }

    it 'joins previous and current `subject` contents' do
      expect(subject).to eq('subject(:value) + subject')
    end

    it 'joins same-name subject and `let` contents' do
      expect(value).to eq('subject(:value) + let(:value)')
    end
  end
end
```

Although knowing the outcome of these tests is informative, it's crucial to note that this scenario is not a common one. In practice, I strongly advise against using `super()` in the `subject` whenever possible. Remember, the `subject` should serve as a starting point for each specification, and calling `super()` within it might violate this principle.

Now, let's shift our focus to exploring the mechanics of the `let!` helper and uncover the do's and don'ts in this area.

## `let!` - the non-lazy alternative

The `let!` construct serves as a non-lazy version of `let` in RSpec. Unlike `let`, `let!` is invoked before the corresponding `it` block. This feature proves useful when you need to set up external records before invoking the `subject`. Consider the following example:

```ruby
class User < ActiveRecord::Base
  def same_name_users
    User.where(name: name)
  end
end

describe User do
  subject(:user) { User.new(name: 'John') }
  
  describe '#same_name_users' do
    subject(:same_name_users) { user.same_name_users }

    let!(:another_john) { User.create!(name: 'John') }

    it 'returns users with same name' do
      expect(same_name_users).to eq([another_john])
    end
  end
end
```

If we were to replace `let!` with `let` in this example, the test would fail. Without `let!`, the `same_name_users` variable would be called and memoized before `another_john` is invoked. As a result, the `same_name_users` would return an empty list.

By using `let!`, we ensure that `another_john` is created and called before the invocation of `same_name_users`, ultimately leading to a passing test. However, an important question arises: when exactly are `let!` variables invoked?

### When exactly are `let!` variables invoked?

As always, things are easy as long as they are simple, but let's see what will happen after mixing `around` and `before` hooks.

To better understand the implications of combining these helpers, I conducted several experiments with `super()` in different scenarios. Here's another handy cheat-sheet presented in RSpec code:

```ruby
describe 'using `let!` with other hooks' do
  def log_call(name)
    @call_log ||= []
    @call_log += [name]
  end

  context 'when let! is defined before other hooks' do
    let!(:value) { log_call('let!') }
    around { |example| log_call('around'); example.run }
    before { log_call('before') }

    it 'runs let! between `around` and `before` hooks' do
      expect(@call_log).to eq(%w[around let! before])
    end
  end

  context 'when let! is defined after other hooks' do
    around { |example| log_call('around'); example.run }
    before { log_call('before') }
    let!(:value) { log_call('let!') }

    it 'runs let! after other hooks' do
      expect(@call_log).to eq(%w[around before let!])
    end
  end

  context 'when `let!` goes first in parent context' do
    let!(:value) { log_call('let!') }
    around { |example| log_call('around'); example.run }
    before { log_call('before') }

    context 'when redefining let! in nested context' do
      let!(:value) { log_call('nested-let!') }

      it 'runs let! between `around`` and `before` blocks' do
        expect(@call_log)
          .to eq(%w[around nested-let! before])
      end
    end
  end

  context 'when `let!` goes last in parent context' do
    around { |example| log_call('around'); example.run }
    before { log_call('before') }
    let!(:value) { log_call('let!') }

    context 'when redefining let! in nested context' do
      let!(:value) { log_call('nested-let!') }

      it 'runs let! after `around`` and `before` blocks' do
        expect(@call_log)
          .to eq(%w[around before nested-let!])
      end
    end
  end
end
```

Based on the outcomes, it becomes evident that `let!` is essentially equivalent to:

```ruby
let(:value) { "something" }
before { value }
```

There are situations where you might want your `let!` variable to execute before the `before` block, while in other cases, you may prefer it to execute after the `before` block. However, keep in mind that such an approach can lead to inconsistencies in your spec structure. Here's a simplified example of code that works but is not considered good practice:

```ruby
let!(:do_it_first) { puts "first" }
before { puts "second" }
let!(:do_it_third) { puts "third" }
before { puts "forth" }
```

Introducing `let!` adds another layer to the already complex flow of RSpec hooks. It is generally better to maintain simplicity and readability in your specs by using a combination of `let` and `before` hooks instead of relying heavily on `let!`. Consider the following alternative approach:

```ruby
let(:do_it_first) { puts "first" }
let(:do_it_third) { puts "third" }

before do 
  do_it_first
  puts "second"
  do_it_third
  puts "forth"
end
```

By adopting this approach, you can keep your codebase clean, easy to follow, and less prone to potential inconsistencies.

## Conclusion

In this article, we delved into the power of RSpec's `let` helper and explored its friendly companions, `let!` and `subject`. `let` offers three key features: redefinability, memoization, and lazy evaluation. By leveraging `let` blocks, developers can enhance test readability, maintainability, and execution speed.

We also explored the usage of `super()` within `let` blocks, which allows us to inherit and extend values defined in the parent context. However, using `super()` in the `subject` is not recommended, as it can violate the principle of the `subject` serving as a starting point for each specification.

Additionally, we investigated the behavior of `let!`, which acts as a non-lazy version of `let`. `let!` is useful for setting up external records before invoking the `subject`, ensuring the proper setup for certain test scenarios. Nevertheless, use of `let!` can complicate the flow of RSpec hooks and lead to inconsistencies in the test structure.

Overall, the article provided a deeper knowledge and insights into the advanced usage of `let` in RSpec. It highlighted the benefits of using `super()` for code reusability, flexibility, and maintainability. By understanding the capabilities of `let` and considering its usage in conjunction with `super()`, developers can optimize their tests and create robust and efficient test suites.