Tired of complex RSpecs? `Let's not` to the rescue

Introduction

Every conscious developer understands the importance of testing their application. Automated tests are advantageous in that they help improve the stability and quality of our software. They also allow us to make changes to our code more securely and can also serve as a form of documentation for our project. Good tests must be readable and should be reliable. Finally, it is important that they can be executed quickly, which in turn encourages us to run them more often. 

The RSpec is a great testing tool for writing tests, but how can we use it in a way that makes what we are testing obvious? How can we use it so that our tests tell a story? Well, have you heard about the four-phase test pattern and the “let's not” idea?

The Four-Phase Test Pattern

Let’s focus firstly on the structure of our test. In xUnit Testing Patterns, Gerard Meszaros introduces the test pattern called “four-phase test.” The goal of this pattern is to improve the readability of the test by structuring each test logic with four distinct parts executed in sequence:

  1. Fixture setup
    In this phase, we should prepare or create all fixtures that are needed for testing.  

  2. Exercise the System-Under-Test (SUT)
    In this phase, we should perform against the SUT.

  3. Result verification
    In this phase, we should check the expected outcome of our test.

  4. Fixture teardown
    This is the cleanup phase. Here, we should make sure that the environment is in a consistent state for subsequent tests. That’s why in the teardown phase, we should remove all fixtures, which were prepared or created in the setup phase.

Below is an example of using the four-phase test pattern:

    Rspec.describe Order do
  context '#confirm' do
    it 'transitions an order to confirm state’ do
		  # fixture setup (phase 1)	
          order = create(:order, state: 'payment') 
          
          # exercise the System-Under-Test (SUT) (phase 2)
          order.confirm
          
          # result verification (phase 3)
          expect(order.state).to eq('confirm'))
    end
  end
end
  

 

For the fixture teardown phase (phase 4) in RSpec with Rails, we can either depend on the RSpec mechanism that performs each test example in the transaction and rollback the transaction at the end of the example or use DatabaseCleaner gem with the proper strategy selected.

Problem with RSpec’s let and let!

We use “let” to define a memoized helper method. The value of this method will be cached across multiple calls in the same example but not between the examples. Since “let” is lazy-evaluated, the value isn’t loaded into memory until the method is called. If we want to load the value into memory before each example we can use “let!”. 

When I started using RSpec, one of the first things I liked about it was “let” and “let!”. In my code, I always tried to follow the DRY principle, so in my tests, I took the same approach. Thanks to using “let”, my tests seemed more readable and DRY and also appeared more like RSpec tests. 

In the RSpec documentation we can find the following note:

    ```
let can enhance readability when used sparingly (1,2, or maybe 3 declarations) in any given example group, but that can quickly degrade with overuse
```
  

While I was aware of this note, it was really hard to use “let” sparingly. At first, I didn't see much of a problem if there were 4 or 8 “let” definitions in one file. Firstly, because everyone in the team knew how “let” worked and used it in a similar way. For example, all the “let” definitions were placed at the top of the file, and then in a given example, they were overwritten if needed. Secondly, I did not know the alternative to using “let”.

I think that a lot of developers share the same experience of overusing “let”. Before I wanted to modify a test, I sometimes had situations in which I had to spend several minutes debugging one example in a test to understand what its setup looked like and what fixtures it needed.

Anatomy of Chaos

Let us assume that we have a system in which we have an “Order” model and our system should fulfill the following acceptance criteria:

  1. To create an order object, we have to assign it to “product” and “customer”. 

  2. Every instance of the “Order” class has a method “complete” that should transit an order state to “complete”. 

  3. Order can be marked as “complete” only when its current state is “payment” and when the product assigned to it is available.

Let’s create a test for the Order#complete method, using  “let”

    RSpec.describe Order, type: :model do describe '#complete' do let(:product) { build(:product, available: true) } let(:customer) { build(:customer) } let(:order) { build(:order, customer: customer, product: product, state: current_state) } before { order.complete! } context 'when current state of the order is `payment`' do let(:current_state) { 'payment' } context 'and product is available' do it 'transitions an order to complete state' do expect(order.state).to eq('complete') end end context 'and product is not available' do let(:product) { build(:product, available: false) } it 'does not transition an order to complete state' do expect(order.state).not_to eq('complete') end end end context 'when current state of the order is `delivery` and product is available' do let(:current_state) { 'delivery' } it 'does not transition an order to complete state' do expect(order.state).not_to eq('complete') end end end end
  

 

As you can see at the top of this test, we have definitions for general fixtures. These fixtures are shared by all examples. Some of the “lets” are then overridden in the given context. There is also “before” that is called before each example, which performs the SUT.

While looking at this test code, we can find a code smell called obscure test. At first glance, it is very difficult to understand this test. Here are two reasons why: 

  1. Mystery Guest - in each example we have too little information. Each example has only the result verification. The fixture setup and the test exercise are done outside the example.

  2. General Fixture - on the top of the file, we have a shared fixture setup. It’s hard to say which of them are needed for the given example. In such a situation, it is easy to make mistakes and create more data than are needed for a given test example. This can lead to slow tests.

RSpec tests from a different angle

My understanding of the RSpec tests was changed by my colleague who sent me a great article from the Thoughtbot blog with the interesting title Let’s Not. 

In a nutshell, this article suggests avoiding RSpec DSL constructs like  “let” or “before” and instead, sticking to the plain old Ruby methods and variables. As we could still be DRY, I decided to try this approach myself.

In the first step let’s remove “before” and move the exercise the SUT phase to the examples:

    RSpec.describe Order, type: :model do describe '#complete' do let(:product) { build(:product, available: true) } let(:customer) { build(:customer) } let(:order) { build(:order, customer: customer, product: product, state: current_state) } context 'when current state of the order is `payment`' do let(:current_state) { 'payment' } context 'and product is available' do it 'transitions an order to complete state' do order.complete! expect(order.state).to eq('complete') end end context 'and product is not available' do let(:product) { build(:product, available: false) } it 'does not transition an order to complete state' do order.complete! expect(order.state).not_to eq('complete') end end end context 'when current state of the order is `delivery` and product is available' do let(:current_state) { 'delivery' } it 'does not transition an order to complete state' do order.complete! expect(order.state).not_to eq('complete') end end end end
  

 

Now we can see that the execution of the test is closer to the example. We don't have to go through the file looking for the execution phase for the given example.

In the second step, let’s replace “lets” with variables and apply the “four-phase test” pattern:

RSpec.describe Order, type: :model do
  describe '#complete' do
    context 'when current state of the order is `payment`' do
      context 'and product is available' do
        it 'transitions an order to complete state' do
          product = build(:product, available: true)
          customer = build(:customer)
          order = build(:order, customer: customer, product: product, state: 'payment')

          order.complete!

          expect(order.state).to eq('complete')
        end
      end

      context 'and product is not available' do
        it 'does not transition an order to complete state' do
          product = build(:product, available: false)
          customer = build(:customer)
          order = build(:order, customer: customer, product: product, state: 'payment')

          order.complete!

          expect(order.state).not_to eq('complete')
        end
      end
    end

    context 'when current state of the order is `delivery` and product is available' do
      it 'does not transition an order to complete state' do
        product = build(:product, available: true)
        customer = build(:customer)
        order =  build(:order, customer: customer, product: product, state: 'delivery')

        order.complete!

        expect(order.state).not_to eq('complete')
      end
    end
  end
end

Now you can see that each example contains “fixture setup”, “exercise the SUT”, “result verification” and “fixture teardown” phases. Looking at the given example, we can see that it tells a story and as a result, the test is easier to read and understand.

In the last step, let’s refactor this test a little bit by moving the shared logic to the method.

    RSpec.describe Order, type: :model do describe '#complete' do def prepare_order(initial_state:, product:) customer = build(:customer) build(:order, customer: customer, product: product, state: initial_state) end context 'when current state of the order is `payment`' do context 'and product is available' do it 'transitions an order to complete state' do product = build(:product, available: true) order = prepare_order(initial_state: 'payment', product: product) order.complete! expect(order.state).to eq('complete') end end context 'and product is not available' do it 'does not transition an order to complete state' do product = build(:product, available: false) order = prepare_order(initial_state: 'payment', product: product) order.complete! expect(order.state).not_to eq('complete') end end end context 'when current state of the order is `delivery` and product is available' do it 'does not transition an order to complete state' do product = build(:product, available: true) order = prepare_order(initial_state: 'delivery', product: product) order.complete! expect(order.state).not_to eq('complete') end end end end
  

 

In our example, we need to prepare an order for each example. We should only extract code-shared by all or most of the examples, otherwise, we might fall into the trap of a general fixture.

These are the main points that you can spot after reading this test:

  1. Thanks to the empty lines, it is easy to see boundaries between phases. 

  2. The only shared code that these examples have is the prepare_order method that creates order. 

  3. In this test, there are no mystery guests and general fixtures.

  4. We don’t have to perform many lookups through the document to understand those examples.

  5. There are more repetitions of the same code as in the example that was using “lets”.

Using “lets”, we also can follow the “four-phase test” principle. Our test could look like this:


RSpec.describe Order, type: :model do
  describe '#complete' do
    context 'when current state of the order is `payment`' do
      context 'and product is available' do
        let(:customer) { build(:customer) }
        let(:product) { build(:product, available: true) }
        let(:order) { build(:order, customer: customer, product: product, state: 'payment') }
​
        before {  order.complete! }
        
        it 'transitions an order to complete state' do
          expect(order.state).to eq('complete')
        end
      end
​
      context 'and product is not available' do
        let(:customer) { build(:customer) }
        let(:product) { build(:product, available: false) }
        let(:order) { build(:order, customer: customer, product: product, state: 'payment') }
​
        before {  order.complete! }
​
        it 'does not transition an order to complete state' do
          expect(order.state).not_to eq('complete')
        end
      end
    end
​
    context 'when current state of the order is `delivery` and product is available' do
      let(:customer) { build(:customer) }
      let(:product) { build(:product, available: true) }
      let(:order) { build(:order, customer: customer, product: product, state: 'payment') }
      
      before {  order.complete! }
​
      it 'does not transition an order to complete state' do
        expect(order.state).not_to eq('complete')
      end
    end
  end
end

As you can see, the “fixture setup” phase is in “lets”, the “exercise the SUT” phase is in the “before” and  “result verification” is in the example. All of these phases are in the given context.  Since the main benefit of having "lets" is that you can be DRY,  I think that such usage would be overkill. 

Moreover, what if we want to check the result of the execution? In such situations, we would have to move the “execution phase” from the “before” block to the “it” block and assign it to some variable. There is a big chance that in some tests there would be an execution phase in the “before” block and in other tests in the “it” block, therefore showing inconsistencies. 

Some people that will follow “lets not” can be tempted to do something like this:

def prepare_order(product:)
  if product
    ...
  else
    ...
  end
end

Instead of that, I would recommend doing two methods like prepare_order_with_product and prepare_order_without_product

Conclusion

After several months of using "lets not" and "four-phase test", I can say that in my opinion, such tests are easier to read and maintain due to fewer lookups needed to understand them. 

The disadvantages of using “lets” are even more apparent in large files. Because in large test files the distance between “lets” and the example is bigger, the lookups that the software developer has to perform are also longer. This disadvantage is even greater when the test has multiple nested contexts in which one overrides the general “lets.”

Since there are more repetitions of the same code when using “let’s not” and “four-phase test”, certain people can say that such code is less DRY or that it’s not DRY enough. I can only say that for me, this is the right balance between DAMP ( Descriptive And Meaningful Phrases) that promotes the readability of the code and DRY. But of course, you can have your own balance between those two ideas.

What is your opinion about “let’s not” and "four-phase test?" Will you give them a shot?

Additional Resources