Rails Testing on Rocket Fuel: How we made our tests 5x faster
By Ollie James – 14th November 2023
For anyone building with Ruby on Rails, you’ll eventually find yourself Googling “How to make Rails tests faster” or “RSpec speed optimisation” or even “Too many FactoryBot records”. The Zero Gravity engineers, who once spent hours scrolling forums for answers themselves, have finally cracked the code (no pun intended). Ollie, one of our engineers, is here to talk you through his foolproof formula for running lightning speed rails tests, to make the platform - and building it - as smooth as possible.
Introduction
Welcome to the fast lane of Rails testing! If you're a software engineer, you're well aware that testing is crucial, but let’s be real: running your test suite can feel like watching paint dry. Those days are over—we're about to fuel up your RSpec and FactoryBot routines!
While our journey specifically applies to an RSpec and FactoryBot testing stack, you’ll likely find the overarching principles useful, no matter your toolkit. Let’s hit the accelerator and explore the three strategies that revolutionised our test suite at Zero Gravity.
Preface: our models
While we provide a multitude of platform features at Zero Gravity, the beating heart of our whole operation is matching: creating mentoring matches between school students and university students. We’ll be using a couple of these matching models as our examples, so here’s a taster:
# models/match.rb
class Match < ActiveRecord::Base
belongs_to :school_student
belongs_to :uni_student
has_many :call_logs
# and many more associations...
end
# models/call_log.rb
class CallLog < ActiveRecord::Base
belongs_to :match
end
Who's to blame?
The prime suspect of slow tests
As we know, factories make it a breeze to create records in tests. Sometimes, though, they’re just a bit too good at their job.
It's pretty common for a test suite to have almost all of its execution time taken up by creating and inserting records into the database. To illustrate my point, take a look at one of our spec files:
# spec/models/call_log_spec.rb
RSpec.describe CallLog, type: :model do
let!(:call_log_1) { create :call_log, :week_old }
let!(:call_log_2) { create :call_log, :month_old }
# ...20 specs...
end
We're creating two CallLog
records using the factory at the start of each test. Through associations, each factory call also creates one Match
record, and that Match
creates a whole mini-verse of other records that are associated with it.
Over one run, this file creates 240 records. But it doesn't need to!
All of these CallLog
records could instead stem from the same Match
, because each of the Match
records we're creating are essentially identical. If we could use the same Match record across every spec, we'd only need to create two new CallLog records per spec, and save TONS of time.
The theory checks out, but can it be done? Oh yes - and here’s how!
The Tools: TestProf
(AKA your personal Sherlock Holmes 🔎)
Enter the TestProf gem, created by the gang over at Evil Martians. It boasts a whole bunch of useful profiling tools, as well as some utility methods to use in specs.
These are the go-to profilers for us at Zero Gravity:
EventProf: collects information about how many times a certain event is fired. We use it here for tracking the
factory.create
event, although you can use any other event.
Usage:EVENT_PROF=factory.create bundle exec rspec
RSpecDissect: tracks how much time is spent in
let
andbefore(:each)
calls and provides the slowest examples.
Usage:RD_PROF=1 bundle exec rspec
FactoryProf: tracks how often each factory is called. This is super helpful when looking at collections of specs and identifying exactly where excess records are being created.
Usage:FPROF=1 bundle exec rspec
or:FPROF=flamegraph bundle exec rspec
These tools are your keys to seeking and evicting the snails squatting in your tests 🐌 All the basic info you need is included in this post, but if you truly want to master these tools, be sure to read Evil Martian’s holy grail blog post on the subject.
Strategy 1: Fix your factories 🏭
Before diving into specs themselves, let's make sure our factories are working as we want them to and not churning out records.
The overproduction problem
Factories can become a real headache, especially when dealing with associations — they’re so eager to create new records that it doesn’t take much for them to run wild. Imagine a User
model that has_one Avatar
. The simplest way of defining associations in a factory is with the implicit definition:
factory :user do
# ...default attributes...
trait :with_avatar do
avatar
end
end
factory :avatar do
user
end
In many cases, this setup will give you an unwelcome additional record. Here's what happens if you call create(:user, :with_avatar)
:
1.
User
factory is called2.
Avatar
factory is called, because of the implicitavatar
association in theuser
factory3.
User
factory is called, because of the implicituser
association in theavatar
factory
Two Users
are created, but we only wanted one! The second User
is created because the Avatar
factory doesn't know that it already has an associated User
.
Our factory is like a puppy - a bundle of energy, and in need of some training.
The Factory Training Guide
We can calm our factories down by changing our syntax, based on the type of association to the record we are creating. In general, let’s imagine two models, Child
and Parent
, and all the possible associations they could have.
When
child belongs_to parent
:factory :child do association(:parent) end
This is the explicit association definition. We're defining the factory we want to use, rather than actually making a factory call. FactoryBot can decide whether it needs to create the record or not. As a bonus, this syntax also supports traits and polymorphic associations.
When
parent has_one child
:factory :parent do child { association(:child, parent: instance) } end
Again, this defines the factory we want to use without calling the factory. Crucially, the block gives us access to the
instance
variable, meaning you can explicitly tell FactoryBot that the child model is associated with the parent model. Sweet!When
parent has_many children
:factory :parent do child { [association(:child, parent: instance)] } end
Similar to
has_one
, but we use an array ofassociation
calls instead of just one. You could build this array dynamically if you wanted.
Strategy 2: Respect your elders 👵
Now to tackle factory cascade: preventing the creation of excess records, even when our factories are calm and working as we intend them to. We can do this by manually creating our shared parent records.
In our earlier example, we wanted each CallLog
to stem from the same Match
. Here's how we'd do that:
RSpec.describe CallLog, type: :model do
let!(:match) { create :match }
let!(:call_log_1) { create :call_log, :week_old, match: match }
let!(:call_log_2) { create :call_log, :month_old, match: match }
# ...20 specs...
end
So now, at the start of each spec, we're creating one Match
that can be used by both CallLogs
. If we were to create more CallLogs
, we could keep using the same Match
.
We can sprinkle some sugar into this using a method from TestProf: create_default
. It lets us say to FactoryBot, "Hey, if we call a factory that needs an associated Match
, use this one that I’ve already made".
RSpec.describe CallLog, type: :model do
let!(:match) { create_default :match }
let!(:call_log_1) { create :call_log, :week_old }
let!(:call_log_2) { create :call_log, :month_old }
# ...20 specs...
end
💡 While this syntax looks cleaner, it's also more implicit and can make the code less readable. Use create_default
tentatively—it's best to only use it for top-level associations, at the start of a file.
Strategy 3: Let it be 🌈
There's one more method from TestProf we're going to use heavily: let_it_be
. The syntax is exactly the same as a let
declaration, but the function is different: once the record is created for the first time, it will be persisted in the database for every spec in the block.
In our example, we wanted to persist the Match
record. let_it_be
makes this trivial:
RSpec.describe CallLog, type: :model do
let_it_be(:match) { create_default :match }
let!(:call_log_1) { create :call_log, :week_old }
let!(:call_log_2) { create :call_log, :month_old }
# ...20 specs...
end
Now we're creating one Match
at the start of the block (and therefore also creating all the associated records that a match needs, but only once). Two CallLog
records are created at the start of each spec, and they both use the existing Match
record as their parent.
💡 Take care when using let_it_be
— any changes that you commit to the database record in one spec will persist over to all subsequent specs.
It's best not to mutate a record created with let_it_be
— if it needs to be mutated in a spec, create a new record just for that spec.
You can happily change the record object in memory, as long as you reload it from the database once the spec is finished. In our codebase, we’ve set the reload
option to true
by default to make this easier, so that the record is reloaded from the database at the start of each spec. Have a proper read of the docs for all the info you need.
The Impact 🚀
Having followed our three strategies, we’ve slashed the number of records that need to be created: the total for our example file has gone from 240 to 45. Furthermore, CallLog
is a pretty light record— Match
and its associations are much more dense and take longer to create—so the total time spent in factories is reduced even more dramatically!
We went all-out with these strategies in the Zero Gravity codebase, finding the slowest tests and turbocharging them one by one until we’d covered our entire test suite.
At the end of it all, we’ve achieved:
A test suite that runs over 5x faster than before ⚡️
A 10x reduction in the total time spent in factories ⚡️
A 70% reduction in the total number of records created ⚡️
In the past, we’d go out to grab a coffee while waiting for our test suite to plod along. Nowadays, we don’t need coffee; we sit and watch the little green dots whizz by and the adrenaline from that fuels us for hours.
Summary
1. Fix your factories (prevent overproduction by using specific syntax)
2. Respect your elders (manually create parent records)
3. Let it be (persist your records across multiple specs)
Follow those three strategies and see the light!
And there you have it—a journey through a transformative approach to Rails testing, unlocking remarkable speed with just three strategies. We'd love to hear how these strategies work for you or any insights you might have, so please do get in touch over at ollie@zerogravity.co.uk to share your thoughts.
Now dive in, give them a go, and watch your Rails tests fly!