Povilas Jurčys
Posted on July 13, 2023
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:
-
let
can be redefinedIn 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 alet
variable within a more specific context, providing flexibility in setting different values for the same variable in different scenarios. -
let
is memoizedlet
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 alet
variable remains consistent throughout the example or context, even if it is referenced multiple times. -
let
is lazy evaluatedlet
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 forlet
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:
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()
inlet
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()
withinlet
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:
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:
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:
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:
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:
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:
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.
Posted on July 13, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.