Things you might not need in your tests

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:

  1. 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.
  2. Use other test types to exercise more specific scenarios. View specs can be useful to test all variants of a particular component, for example.
  3. 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.