P1057614 1
Inside Zero Gravity HQ

Rails Testing on Rocket Fuel: How we made our tests 5x faster

IMG 6390

By Ollie James – 14th November 2023

Back to Blog


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 and before(: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. 1. User factory is called

  2. 2. Avatar factory is called, because of the implicit avatar association in the user factory

  3. 3. User factory is called, because of the implicit user association in the avatar 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 of association 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. 1. Fix your factories (prevent overproduction by using specific syntax)

  2. 2. Respect your elders (manually create parent records)

  3. 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!



IMG 6390

Ollie James

Software Engineer

Ollie is one of the Zero Gravity engineers, working to build the platform, keep everything ship-shape and running tidily, and battling any nasty bugs in our code. He graduated with a degree in Computer Science and Maths from the University of Bath in 2020, and began working at Zero Gravity in May 2022. Got a question for Ollie? Pop him and email over at ollie@zerogravity.co.uk and he'll be sure to reply (he's lovely).

View all their articles