Fast tests are desirable, but oftentimes as the project evolves and the necessity of going fast kicks in and writing tests feels like an obstacle leading to less attention to quality, let alone efficiency, and speed.
Here is a curated list of strategies I’ve used in the past to drastically improve the speed of my RSpec test suite:
Do not include Rails in every test
If you are familiar with RSpec you are probably aware of these two files: spec_helper.rb
and rails_helper.rb
.
I’d like to walk you through what their purpose is and why are there two files instead of just one.
spec_helper
This should be RSpec’s lightweight configuration, allowing it to be incredibly fast while only including the least amount of dependencies and config to do the job.
My strategy is to always default to the spec_helper, and if need for additional dependencies (or Rails), then and only then switch to the rails_helper
.
This might not sound like a lot, but trust me it is. Imagine having 1000 test examples and before each of them including and executing a helper only required by one test.
If you are into TDD, the individual test will load files and run in under a second (or even a tenth of a second), boosting your development productivity, especially if you use the Guard gem.
You want to have the spec_helper
as lightweight as possible and be aggressive in leaving it that way.
rails_helper
Here go additional helpers, that usually are required only for a hand of your test suite, for instance stubbing a mailer to stop from doing HTTP requests, or Stripe additional configuration. Notice that only a small group of your tests should include the rails_helper
, not your entire suite.
This saved us a lot.
I want this chapter to allow you to add these new questions into your thought process when writing tests:
- Do I need the entire Rails folder to test this?
- What other dependencies do I need to be able to test this?
Analyze your factories
FactoryBot is a great tool which an easy DSL to work with, but sometimes it can be easy to overwhelm a factory definition with instructions that add up unnecessary seconds to the test. There is a great gem to profile your factories called test-prof.
This will show you a breakdown of the number of factory invocations per type, also the amount of time each factory takes to persist its data.
Using it you can analyze and tweak your factories to be more efficient and eliminate insignificant configurations.
$ FPROF=1 rspec spec/controllers
[TEST PROF INFO] Factories usage
Total: 574
Total top-level: 376
Total time: 00:01.714 (out of 00:02.388)
Total uniq factories: 18
total top-level total time time per call top-level time name
130 130 0.5644s 0.0043s 0.5644s user
128 104 0.6530s 0.0051s 0.5746s actor
81 61 0.1450s 0.0018s 0.1121s spectator
76 2 0.0644s 0.0008s 0.0013s premium_plan
74 0 0.5573s 0.0075s 0.0000s profile
25 23 0.1174s 0.0047s 0.0952s subscription
20 16 0.2212s 0.0111s 0.1864s bonus
...
One of the most expensive configurations for a factory is its associations.
FactoryBot.define do
factory :user do
sequence :email do |n|
"user#{n}@example.net"
end
first_name { 'test' }
# Careful with this one
association :city
# Same with this
after(:create) do |u|
u.create_profile!
end
end
end
Notice in this example once a user is created it automatically creates an associated city, which might not be needed for every test example.
I know it’s much more convenient to only call create(:user)
instead of create(:user, city: city)
, but this alone will save seconds.
There is also the after(:create)
hook which creates a profile after each user is created, again something that might not be needed everywhere, so factories need to be carefully written mindful of performance.
Persistence is expensive
For convenience, we might rely on always persisting models, although many times we don’t need to save the record to the database.
Instead of using FactoryBot.create
, try and go the other way and use FactoryBot.build_stubbed
as your default and only switch when needing persistence.
require 'spec_helper'
RSpec.describe UserSerializer do
subject(:serializer) { described_class.new(user).serializable_hash }
let(:user) { build_stubbed(:user) }
it 'returns the correct attributes' do
expect(serializer[:data]).to include(
name: user.name,
email: user.email
)
end
end
In this example, we want to test that a Serializer object works, but although we could create
a user to test this, there is no persistence needed, so build_stubbed
works just fine.
Profile your slowest tests
There is a config you can add to spec_helper
:
config.profile_examples = 3
This allows you to see your 3 slowest examples so you can identify their bottlenecks.
$ rspec spec/requests
Top 3 slowest examples (0.50012 seconds, 2.8% of total time):
GET /api/actors when authenticated with valid params has the correct count
0.20219 seconds ./spec/requests/actors_spec.rb:60
GET /api/actors when authenticated with valid params returns a 200
0.15047 seconds ./spec/requests/actors_spec.rb:48
GET /api/actors when authenticated with valid params has the correct meta information
0.14747 seconds ./spec/requests/actors_spec.rb:66
Stub external HTTP requests
Use a library like webmock
and block external HTTP requests.
For example, the next snippet would block any request that goes to Mandrill in order to send a transactional email.
This alone can save seconds for each example because you won’t be waiting for any HTTP request.
# config/rails_helper.rb
config.before do
stub_request(:any, /mandrillapp.com/)
.to_return(status: 200, body: [{ '_id' => 'test' }].to_json)
end
Conclusion
Make sure you visualize your test suite from a high-level view and understand the heavy dependencies. Have a habit of profiling your test performance to minimize the bottlenecks.
Key takeaways:
- Make the
spec_helper
as light as possible and start the test by including it. - Keep factories as minimal as possible.
- Use
build_stubbed
when possible instead ofcreate
. - Profile your slowest tests.
- Stub external HTTP requests.