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

Migrating from Test::Unit to Minitest for Faster CI Pipelines


In 1913, Henry Ford introduced the moving assembly line to automobile manufacturing. Before this innovation, workers would move around a stationary car, often getting in each other’s way and sharing the same tools. The process was sequential and slow. By moving the work to the workers and allowing them to operate in parallel on different components, Ford reduced the assembly time of a Model T from 12.5 hours to 93 minutes.

Of course, this article isn’t about manufacturing Model Ts — it’s about software engineering. Nevertheless, though, much like Ford found sequential assembly to be a bottleneck, we often find the same is true for how we verify our code.

For a mature Ruby on Rails application with over 10,000 tests, running the test suite sequentially can typically take 40 minutes or more. This creates a significant bottleneck. Developers wait for CI builds before merging pull requests, context switching degrades productivity, and the organization pays a premium for CI infrastructure that runs long, inefficient jobs.

If we want to address these issues, we need to optimize our Rails test suite speed. One practical approach to reduce CI infrastructure costs and save developer time is migrating from Test::Unit to Minitest, which allows us to take advantage of modern parallelization features.

The Shift to Parallelization

Historically, many legacy Rails applications relied on Test::Unit or older versions of RSpec, running tests one by one. While reliable, this approach fails to utilize modern multi-core CI runners effectively.

There are, of course, multiple ways to parallelize a test suite. For RSpec users, gems like parallel_tests have been available for years. Some teams prefer the philosophy and syntax of RSpec, so they adopt external tooling to achieve concurrency. However, with the release of newer Ruby and Rails versions, the ecosystem has embraced parallel testing as a first-class feature in Minitest. By upgrading to a modern Rails version and migrating your test suite to Minitest, you gain access to a powerful built-in mechanism for parallel execution without relying on third-party dependencies.

In Minitest, we can instruct Rails to use all available CPU cores by adding a single configuration line to our test_helper.rb:

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

When deployed across 14 to 16-core CI machines, this configuration can theoretically reduce a 40-minute test suite down to less than 4 minutes.

Strictly speaking, parallelize forks your Ruby process into multiple workers. Each worker gets its own database connection — Rails automatically adds a suffix to the database name (e.g., _test-1, _test-2) — to ensure data isolation.

However, as with Henry Ford’s factory workers, asking multiple processes to work simultaneously introduces a new class of problems. The database is isolated, but what happens when two workers reach for the exact same file or shared resource at the exact same time?

Understanding Minitest Race Conditions

When we run tests sequentially, the environment is predictable. Test A runs, creates a file, asserts a condition, and deletes the file. Then Test B runs.

When we enable parallelize, each worker operates independently and simultaneously. This frequently exposes latent architectural issues in how our tests interact with the filesystem and shared state, resulting in Minitest race conditions.

Let’s examine three common causes of these race conditions and how to remediate them.

1. Hardcoded Directories

The most frequent cause of parallel test failures is the use of hardcoded directory paths.

For example, suppose we have a test that verifies a file upload feature. We might write a test that saves a generated PDF to /tmp/exports/report.pdf.

If Worker 1 and Worker 2 both execute tests that write to this exact path simultaneously, Worker 2 might overwrite the file before Worker 1 attempts to assert its contents. Worker 1 will then fail, seemingly randomly.

To resolve this, we must ensure that each worker operates in its own isolated namespace. Rails provides an environment variable ENV['TEST_ENV_NUMBER'] which is automatically set for each worker process (e.g., ‘1’, ‘2’, ‘3’, or nil for the first worker).

Instead of a hardcoded path, we can construct an isolated directory for each worker. We could add a helper method to our test_helper.rb:

def worker_tmp_dir
  path = Rails.root.join("tmp", "test_exports", "worker_#{ENV['TEST_ENV_NUMBER'] || '1'}")
  FileUtils.mkdir_p(path)
  path
end

By ensuring each worker writes to tmp/test_exports/worker_1, tmp/test_exports/worker_2, and so on, we eliminate the collision.

2. Aggressive Pessimistic Deletion of Temporary Folders

Another common pattern in sequential testing is cleaning up the environment before a test runs, rather than after. We call this pessimistic deletion.

A test setup block might look like this:

setup do
  # Pessimistically ensure a clean state
  FileUtils.rm_rf(Rails.root.join("tmp", "cache", "downloads"))
  FileUtils.mkdir_p(Rails.root.join("tmp", "cache", "downloads"))
end

In a parallel environment, this is catastrophic. Worker 1 might be halfway through asserting the contents of a downloaded file when Worker 2 begins its setup block. The aggressive pessimistic deletion of temporary folders clears the entire downloads directory, and Worker 1 fails.

The solution requires two changes. First, as discussed above, isolate the paths per worker. Second, prefer teardown blocks or block-based temporary directories that clean up only their specific artifacts, rather than clearing shared parent directories.

setup do
  @test_dir = Dir.mktmpdir("downloads_#{ENV['TEST_ENV_NUMBER'] || '1'}")
end

teardown do
  FileUtils.remove_entry(@test_dir) if defined?(@test_dir)
end

Using Ruby’s built-in Dir.mktmpdir guarantees a unique directory for that specific test execution, preventing workers from deleting each other’s data.

3. Shared File Modifications

Beyond temporary files, tests often interact with configuration files or application-level state. For example, a test might temporarily modify a settings.yml file to verify how the application handles a missing configuration key.

If Worker 1 triggers modifications on a configuration file that Worker 2 relies on, Worker 2 will fail unexpectedly.

The pragmatic solution is to avoid modifying actual files on disk during tests whenever possible. Instead, we can mock the configuration reader or use dependency injection.

If we absolutely must modify a file, we should duplicate the file into the worker’s isolated directory and configure our test environment to read from the worker-specific copy, rather than the global file.

# In test_helper.rb
setup do
  if ENV['TEST_ENV_NUMBER']
    # Redirect configuration to a worker-specific path
    AppConfig.config_path = Rails.root.join("tmp", "config_#{ENV['TEST_ENV_NUMBER']}.yml")
  end
end

Of course, this approach requires that our application configuration logic respects the config_path override.

The Long-Term Value of Test Isolation

Migrating from Test::Unit to Minitest and resolving these race conditions requires upfront investment. Depending on the size of the codebase, we might spend several days identifying and fixing poorly isolated tests.

However, this investment pays continuous dividends. Reducing a test suite from 40 minutes to 4 minutes fundamentally changes how a development team operates. It encourages smaller, more frequent commits, reduces context switching, and significantly lowers the monthly bill for CI infrastructure.

By forcing our test suite to be truly isolated and parallel-safe, we are not making it faster — we are making it structurally sound and sustainable for years to come.

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