Speed up Rspec tests with let_it_be

CI/CD plays a critical role in most of modern software development processes nowadays. By improving running speed of tests, you can help team to quickly detect issues, deliver faster, reduce pipeline's resources and so the cost.

Typical rspec test

Usually a typical rpsec test includes 2 part

  • Prepare data: let block
  • Assertion: it block
# frozen_string_literal: true

RSpec.describe UserFinder, type: :finder do
  describe 'adults' do
    subject { described_class.new(age_of_majority).adults }

    context 'nowadays' do
      let(:age_of_majority) { 18 }
      let(:user_1) { FactoryBot.create(:user, age: 10) }
      let(:user_2) { FactoryBot.create(:user, age: 15) }
      let(:user_3) { FactoryBot.create(:user, age: 20) }

      it do
        expect(subject.length).to eq(1)
      end
    end

    context 'Shakespeare time' do
      let(:age_of_majority) { 13 }
      let(:user_1) { FactoryBot.create(:user, age: 10) }
      let(:user_2) { FactoryBot.create(:user, age: 15) }
      let(:user_3) { FactoryBot.create(:user, age: 20) }

      it do
        expect(subject.length).to eq(2)
      end
    end

    context 'Pokemon world' do
      let(:age_of_majority) { 8 }
      let(:user_1) { FactoryBot.create(:user, age: 10) }
      let(:user_2) { FactoryBot.create(:user, age: 15) }
      let(:user_3) { FactoryBot.create(:user, age: 20) }

      it do
        expect(subject.length).to eq(3)
      end
    end
  end
end

As above we have a simple testsuit of "adults" method inside "UserFinder" class, which contains 3 contexts. The class accept a paramter to decide who is eligible to be an adult. In each context, we redefine age_of_majority, as well as user_1, user_2, user_3 which act as sample data.
it blocks contain an expression checking the expected outputs

These tests work fine but with 2 drawbacks.

  • It's verbose
  • Using let will keep removing user_1, user_2, user_3 after finish one assertion and re-create them in another context even if they are all the same (by its meaning for the context). As the tested method is all about finding data in DB, so persisting and removing the same large data frequently is not a good idea.

Using "before" hook

We can use "before" hook like below

RSpec.describe UserFinder, type: :finder do
  describe 'adults' do
    subject { described_class.new(age_of_majority).adults }

    before(:all) do
      FactoryBot.create(:user, age: 10)
      FactoryBot.create(:user, age: 15)
      FactoryBot.create(:user, age: 20)
    end

    context 'nowadays' do
      let(:age_of_majority) { 18 }
      it do
        expect(subject.length).to eq(1)
      end
    end

    ....
  end
end

It seems to be less verbose but, under the hood, the process works exactly the same, and thus no thing changes in term of performance.

Using "before_all" hook and let_it_be

before_all help you to wrap whole tests into a transaction, means these users can be created only one time and visible until out of its scope.

RSpec.describe UserFinder, type: :finder do
  describe 'adults' do
    subject { described_class.new(age_of_majority).adults }

    before_all do
      FactoryBot.create(:user, age: 10)
      FactoryBot.create(:user, age: 15)
      FactoryBot.create(:user, age: 20)
    end

    ....
  end
end

This significantly improve the speed, however when you want to compare a specified value of prepared data, you may want to change it like this:

  @user_1 = FactoryBot.create(:user, age: 10)

and

  expect(subject.last.id).to eq(@user_1.id)

Instance variables work fine but it's hard to maintain. let_it_be acts the same function but makes things easier to use.

# frozen_string_literal: true

RSpec.describe UserFinder, type: :finder do
  let_it_be(:user_1) { FactoryBot.create(:user, age: 10) }
  let_it_be(:user_2) { FactoryBot.create(:user, age: 15) }
  let_it_be(:user_3) { FactoryBot.create(:user, age: 20) }

  describe 'adults' do
    subject { described_class.new(age_of_majority).adults }

    context 'nowadays' do
      let(:age_of_majority) { 18 }
      it do
        expect(subject.last.id).to eq(user_3.id)
      end
    end

    context 'Shakespeare time' do
      let(:age_of_majority) { 13 }
      it do
        expect(subject.last.id).to eq(user_2.id)
      end
    end

    context 'Pokemon world' do
      let(:age_of_majority) { 8 }
      it do
        expect(subject.last.id).to eq(user_1.id)
      end
    end
  end
end

It's not only much easier to read but also better in performance

Thank you!