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:
- Test the persistence of an object.
- 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! 🤓