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! 🤓
Please leave your comments below if you have any questions / thoughts or if you know of other practices we can apply to speed up our Rails test suite. ✌️
Top comments (0)