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 removinguser_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!