When more features are added to our application, the time it takes to run our tests increases. The pain of this is more evident when we work in a team of fellow developers, pushing to the repository and triggering builds on the CI platform multiple times per day! 😭 But don’t worry, there are 3 practices we can apply to speed up our test suite!

1. Replace let! variables with let variables

Why should we replace let! with let? How does using let help speed up our test suite?
Let’s first take a look at what let! does behind the scenes. 🕵️‍♂️

This is how the let! method looks like in rspec-core’s codebase.

def let!(name, &block)
  let(name, &block)
  before { __send__(name) }
end

As we can see, it basically calls let and before. But notice the block passed to before.

{ __send__(name) }

This will invoke the method which is identified by the name parameter before each example is run. Let’s move onto to an example!

This service was written as dumb as possible for the sake of this example. 😁 The service returns books that were fetched from a fictional platform called Bibliothek.

module Bibliothek
  class GetBooks
    def self.run
      new.run
    end

    def run
      [
        {
          title: 'And Then There Were None',
          author: 'Agatha Christie'
        },
        {
          title: 'Far from the Madding Crowd',
          author: 'Tom Hardy'
        },
        {
          title: 'This Was a Man',
          author: 'Jeffrey Archer'
        }
      ]
    end
  end
end

And we have this spec for the service above.

describe Bibliothek::GetBooks, type: :service do
  describe '.run' do
    let!(:titles) do
      # An expensive process
      sleep(2)

      ['And Then There Were None', 'Far from the Madding Crowd', 'This Was a Man']
    end
    let(:authors) { ['Agatha Christie', 'Tom Hardy', 'Jeffrey Archer'] }

    subject { described_class.run }

    it 'has 3 book records' do
      expect(subject.length).to eq(3)
      expect(subject.pluck(:author)).to eq(authors)
      expect(subject.pluck(:title)).to eq(titles)
    end

    it { should be_a Array }
  end
end

Even though we’re not explicitly invoking titles, the titles variable will be evaluated for each example thanks to the way let! was written. The time it takes for the entire .run example group to run is 4 seconds on average!

How can we enhance this?
We convert the titles variable from a let! into a let.

describe Bibliothek::GetBooks, type: :service do
  describe '.run' do
    let(:titles) do
      # An expensive process
      sleep(2)

      ['And Then There Were None', 'Far from the Madding Crowd', 'This Was a Man']
    end
    # other code
  end
end

Now, the time it takes is slashed by half! 🎉🎉🎉
The key here is to remember that a let variable is only evaluated when we explicitly invoke it.

2. Write multiple expectations in one single example

Sometimes, it’s unnecessary to write an example (it) for each expectation (expect, should etc.).
Although it’s cleaner (who doesn’t love short one-liners? 😍), it can affect the performance of our tests.
How?

Say, we have a service that updates the availability of a book.

module Books
  class UpdateAvailability
    attr_reader :book

    def initialize(book)
      @book = book
    end

    def self.run(book)
      new(book).run
    end

    def run
      # An expensive process
      sleep(5)

      book.update(available: !book.available)
      OpenStruct.new(success?: true, book: book)
    end
  end
end

And we have this spec.

describe Books::UpdateAvailability, type: :service do
  describe '.run' do
    let(:book) do
      create(
        :book,
        title: 'And Then There Were None',
        available: true,
        author: create(:author, name: 'Agatha Christie')
      )
    end

    subject { described_class.run(book) }

    it { expect(subject.success?).to be true }
    it { expect(subject.book.available).to be false }
  end
end

The time it takes on average is a staggering 10 seconds! Why is this the case?
The subject is executed for each example, in our case being 2 examples. This is unnecessary, and it makes no sense for us to separate the expectations when they’re clearly related to the data returned by described_class.run(book).

What can we do? It’s simple. We group all expectations in one example.

describe Books::UpdateAvailability, type: :service do
  describe '.run' do
    let(:book) do
      create(
        :book,
        title: 'And Then There Were None',
        available: true,
        author: create(:author, name: 'Agatha Christie')
      )
    end

    subject { described_class.run(book) }

    it do
      expect(subject.success?).to be true
      expect(subject.book.available).to be false
    end
  end
end

This will reduce the time taken by half! 🎉🎉🎉

3. Use build_stubbed when persisted objects aren’t required for the test

There are times when the tests we write do not require us to use persisted objects. And that’s when we realise we should use build_stubbed instead of build or create. build_stubbed will stub out methods relevant to persistence. In other words, you won’t be able to:

  1. Test the persistence of an object.
  2. Test the callbacks that should execute after updating an object.

These are the methods excluded from the stubbed object returned by build_stubbed. (retrieved via factory_bot’s code base)

DISABLED_PERSISTENCE_METHODS = [
  :connection,
  :decrement!,
  :delete,
  :destroy!,
  :destroy,
  :increment!,
  :reload,
  :save!,
  :save,
  :toggle!,
  :touch,
  :update!,
  :update,
  :update_attribute,
  :update_attributes!,
  :update_attributes,
  :update_column,
  :update_columns,
].freeze

Conclusion

These three simple practices allow our test suite to run faster, but the gains varies. If we’ve not applied the first two practices, then the more complex and larger the codebase is, the more the amount of gains we can extract. But don’t expect any massive gains from using build_stubbed! 😝
If you want to take a look at the code, feel free to visit the repository.
Cheers for reading and I hope you find this article helpful! 🤓