The go-to resource for upgrading Ruby, Rails, and your dependencies.

From 40 Minutes to 4: Parallelizing Your Rails Test Suite


Before we delve into the mechanics of parallelization, we must first examine why test execution time matters. A test suite that takes 40 minutes to run is a significant bottleneck for developers waiting on continuous integration (CI) feedback. It discourages the practice of running tests locally, leads to context switching, and significantly inflates cloud computing bills.

When we discuss Rails test suite speed optimization, we are ultimately addressing both developer productivity and CI infrastructure cost reduction. Every minute your CI servers spend churning through a serial test queue is money spent without business value.

In a recent project, we encountered an application undergoing a major transition: upgrading to Rails 8.1.1 and Ruby 3.4.7, while simultaneously migrating a suite of over 10,000 tests from legacy Test::Unit to Minitest. The suite originally took roughly 40 minutes to complete sequentially. By leveraging Minitest’s built-in parallelization across 14 to 16-core CI machines, we reduced that time to four minutes.

Of course, this is not a matter of flipping a single configuration switch. Distributing tests across multiple processes inevitably exposes brittle code and hidden dependencies.

The Mechanics of Minitest Parallelization

Ruby, strictly speaking, has a Global Interpreter Lock (GIL) that limits true concurrency when using threads for CPU-bound tasks. To bypass this limitation, Rails uses process-based parallelization for its test runner.

When you enable parallelization in Rails, it forks the main process into multiple worker processes. Each worker receives its own isolated database schema (for example, test-env-0, test-env-1), preventing database-level race conditions when multiple tests attempt to create or modify records simultaneously.

To enable this feature, we update our test_helper.rb to utilize the available processors:

class ActiveSupport::TestCase
  # Run tests in parallel with specified workers
  parallelize(workers: :number_of_processors)

  # Setup all fixtures in test/fixtures/*.yml
  fixtures :all
end

When we execute this on a 16-core CI machine, Rails spawns 16 distinct processes to consume the test queue. However, while the database is isolated per process, the file system is not. This shared file system is where we run into trouble.

Confronting Minitest Race Conditions

When transitioning a legacy test suite to run in parallel, you will almost certainly encounter intermittent failures. These Minitest race conditions occur when tests rely on shared global state outside of the database environment.

During our migration, we identified three primary categories of race conditions that caused our parallel builds to fail unpredictably:

  1. Hardcoded directories
  2. Aggressive pessimistic deletion
  3. Shared file modifications

Let us examine each of these edge cases and how to resolve them.

Hardcoded Directories

Many older tests assume they are the only process interacting with the file system. For example, tests that verify CSV exports or image uploads might write to a hardcoded tmp/exports/ directory.

# Flawed approach: Hardcoded shared directory
test "exports user data" do
  export_path = Rails.root.join("tmp", "exports", "users.csv")
  Exporter.run(export_path)
  
  assert File.exist?(export_path)
end

When Worker A and Worker B run similar tests simultaneously, Worker B might overwrite users.csv before Worker A has a chance to assert its existence. This causes Worker A’s test to fail.

To resolve this issue, we must ensure each worker writes to an isolated directory. We can use the worker’s unique environment variable, ENV["TEST_ENV_NUMBER"], to namespace our temporary directories:

# Robust approach: Worker-specific directory
test "exports user data" do
  worker_id = ENV.fetch("TEST_ENV_NUMBER", "0")
  export_path = Rails.root.join("tmp", "exports-#{worker_id}", "users.csv")
  Exporter.run(export_path)
  
  assert File.exist?(export_path)
end

Aggressive Pessimistic Deletion

A related issue occurs when teardown blocks aggressively clean up directories. We often see teardown methods structured like this:

teardown do
  FileUtils.rm_rf(Rails.root.join("tmp", "exports"))
end

If Worker A finishes its test and executes this teardown block, it deletes the entire exports directory — even if Worker B is currently in the middle of writing a file to that same directory. This aggressive pessimistic deletion results in unpredictable Errno::ENOENT (No such file or directory) errors.

The solution is to isolate the cleanup to the worker-specific directory, ensuring that one worker cannot destroy the temporary files of another.

Shared File Modifications

The most difficult race conditions to debug involve shared file modifications. Some legacy applications write configuration files or cache files to the disk during initialization or specific test runs.

If your application dynamically modifies a file like config/settings.yml during a test, any other worker reading that file simultaneously will receive unexpected data.

# Flawed approach: Modifying shared configuration
test "changes global settings" do
  File.write(Rails.root.join("config", "settings.yml"), "theme: dark")
  assert_equal "dark", Settings.theme
end

Instead of modifying actual files on disk, tests should use stubbing or in-memory configuration overrides provided by Rails.

# Robust approach: In-memory stubbing
test "changes global settings" do
  Settings.stub(:theme, "dark") do
    assert_equal "dark", Settings.theme
  end
end

The Results: From 40 Minutes to 4

After methodically identifying and resolving these race conditions, we successfully stabilized the parallel test suite. Running the suite across 14 to 16-core CI machines yielded measurable results.

By dropping the execution time from 40 minutes down to 4 minutes, we fundamentally altered the development workflow. Developers no longer context-switch while waiting for CI; they receive nearly immediate feedback. Furthermore, the CI infrastructure cost reduction is substantial, as server instances are spun down an order of magnitude faster.

While upgrading to Rails 8.1.1 and Ruby 3.4.7 provided a modern foundation, it was the transition to parallelized Minitest that delivered the most immediate operational benefit.

Parallelizing a large test suite requires upfront investment to untangle shared state and isolate file systems. However, the long-term implications for team velocity and infrastructure costs make it an essential undertaking for any mature Rails application.

Sponsored by Durable Programming

Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.

Hire Durable Programming