No one likes slow and brittle tests, in particular if you’re working a TDD workflow. Often, tests get that way because they’re doing too much. Consider avoiding the following:
Using a database
Database access imposes many costs on your tests. You need to setup a database, connect to it, clean it between test runs, etc. And then each call to the db is slow.
In a typical Rails app, it’s unlikely that you’ll be able to avoid the database entirely, but you can reduce its usage. For instance, let’s consider this model:
class User < ApplicationRecord
def first_name = name.split.first
end
If we would test this method, there’s no need to hit the database since we’re only dealing with in-memory values.
test "should return the first name" do
# Instantiate the model, but don't save it to the database
user = User.new(name: "John Doe")
assert_equal "John", user.first_name
end
If you’re not fetching, saving, updating, deleting or counting records, you probably don’t need the database and can just instantiate the model.
A single test might not make a big difference, but doing it consistently across the suite will make it run faster and more reliably.
Associations
Factories are great, but be careful with your associations. It is easy to cause a long nested chain of records created that aren’t strictly necessary for the test running. To avoid this, prefer creating associations via traits, so you’ll always be explicit when requiring an association. It will help you not to create unnecessary records.
FactoryBot.define do
factory :author do
name { "John Doe" }
trait :with_book do
after(:create) do |author|
create(:book, author: author)
end
end
end
end
If you can, use build_stubbed
instead of
create
/build
to avoid hitting the database in your factories. But remember that not using
the FactoryBot at all will be even faster.
Tracking database changes
Another tip is to disable gems that track database changes, like paper_trail
in tests:
# config/environments/test.rb
PaperTrail.enabled = false
You can always re-enable it in a specific test if you need to test that behavior:
# spec/support/paper_trail.rb
def with_paper_trail
was_enabled = PaperTrail.enabled?
was_enabled_for_request = PaperTrail.request.enabled?
PaperTrail.enabled = true
PaperTrail.request.enabled = true
yield
ensure
PaperTrail.enabled = was_enabled
PaperTrail.request.enabled = was_enabled_for_request
end
This avoids creating several unnecessary records in the database, which slow down your tests. On that note…
Creating several records
If you can’t avoid hitting the database, you should try to reduce the number of records you create. As a rule of thumb, you should create as few records as possible. In tests, 2 records == many records. If you need to create tens of records to test something like pagination, it is worth making that threshold configurable so you can cause pagination with just a couple of records.
# config/environments/test.rb
Rails.application.configure do
config.to_prepare do
Pagy::DEFAULT[:limit] = 2 # instead of, say, 20
end
end
Security
Unless you’re testing security-related code, disable high-security features, like strong password hashing algorithms in your tests. These are usually slow, so it’s a quick win to disable/lower them in tests.
If you’re using Devise, for instance, you should get this automatically in the config file:
Devise.setup do |config|
config.stretches = Rails.env.test? ? 1 : 10
end
But if you’re using something like the Rails 8 authentication generator, add
this to your config/environments/test.rb
:
BCrypt::Engine.cost = BCrypt::Engine::MIN_COST
That will make the password hashing algorithm faster – but less secure – in your tests.
Driving with a real browser
If you can avoid using a browser (by using request tests or RackTest instead of Selenium), go for it! That alone can make things 100x faster – and much more reliable.
But if you have to use a browser, consider diminishing its usage. For instance, instead of filling out and submitting a form to sign in users in tests, use a backdoor to assign cookies and create a session directly.
def login_as(user)
Current.session = user.sessions.create!
cookies = ActionDispatch::Request.new(Rails.application.env_config).cookie_jar
cookies.signed[:session_id] = { value: Current.session.id, httponly: true, same_site: :lax }
end
Also check out the Chrome DevTools Protocol for interacting with the browser in tests – in Ruby you can access that via Cuprite –. That can lead to up to 30% faster tests.
Logging
Logging is generally unnecessary on tests, so disable it. Less code running (and less IO being performed) means faster. You can control that with an env var, so you can enable it back when you need to debug something:
# in config/environments/test.rb
Rails.application.configure do
unless ENV['TEST_LOGS']
config.logger = Logger.new(nil)
config.log_level = :fatal
end
end
Plus, consider using the block syntax to avoid unnecessary allocations:
# bad
Rails.logger.info "Something"
# good
Rails.logger.info { "Something" }
Run fewer tests!
The less code you run, the faster it will finish and the faster you’ll
discover failures. In development, you can use rails test && rails test:system
instead of rails test:all
. If integration and unit tests run first and fail,
you get feedback faster. Only then run system specs. If you’re using RSpec, you
can do something similar by using the --fail-fast
option.
Before deploying, you might want to run a suite of smoke tests that run first, and then the full test suite. That way you can catch issues sooner.
Test pyramid
In general, keep the test pyramid in mind:
- Don’t lean too much on system tests, as they’re slow and brittle. Use them as smoke tests for the most important paths in your application.
- Use other test types to exercise more specific scenarios. View specs can be useful to test all variants of a particular component, for example.
- Write more request tests, which are a good middle ground between unit and system tests.
These guidelines help keep our test suites fast and reliable, which means that we’ll run and trust them that much more.
Got another tip? Let me know and I’ll add it to the list.