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

Fixing Race Conditions in Minitest After Upgrading to Rails 8


When upgrading a large and complex application to Rails 8, you might encounter a sudden increase in flaky tests within your CI/CD pipeline. Often, these intermittent failures are not bugs in your application code, but rather race conditions exposed by Minitest when running in parallel.

Rails has supported parallel testing since version 6, but upgrades often involve performance tuning where teams increase worker counts or switch from processes to threads (parallelize(with: :threads)). Furthermore, Ruby 3.x’s concurrency improvements and Rails 8’s updated dependencies can change execution timing enough to uncover latent state-sharing bugs that previously went unnoticed.

Fixing these race conditions is a critical part of technical debt remediation. A reliable test suite is your safety net for post-upgrade support and future development. In this article, we will explore why these race conditions occur and how to fix them, ensuring a battle-tested workflow for your Rails 8 application.

Understanding Parallel Testing in Minitest

Before we look at the solutions, we need to understand how Rails executes tests concurrently. By default, Rails uses processes to parallelize tests (parallelize(with: :processes)). When using processes, each worker gets its own memory space and database connection, which naturally isolates most state.

However, many teams opt for thread-based parallelization (parallelize(with: :threads)) to reduce memory overhead, or they rely on external services like Redis and Elasticsearch that are not inherently isolated between processes. When multiple tests interact with the same global state simultaneously, race conditions occur.

1. Isolate External Services Like Redis

A common source of test flakiness involves external data stores. If two parallel test workers write to the same Redis key or Elasticsearch index, one test can overwrite the data expected by the other.

When running tests in parallel, Rails provides an environment variable, ENV['TEST_ENV_NUMBER'], which contains the worker’s ID. You can use this to namespace your external services.

For Redis, you might configure your test environment to use a different database number for each worker:

# config/environments/test.rb
worker_id = ENV['TEST_ENV_NUMBER'].to_i
config.cache_store = :redis_cache_store, { url: "redis://localhost:6379/#{worker_id}" }

By ensuring each worker interacts with a completely isolated instance or namespace of the service, you eliminate the risk of cross-worker contamination.

2. Eliminate Global State Mutation

Ruby’s flexibility allows you to easily mutate global state, such as constants, class variables (@@state), or the ENV hash. While this might seem harmless in a single-threaded test suite, it is a primary cause of race conditions when tests run in parallel threads.

If a test must modify a global setting, you must ensure the change is localized and safely reverted. Instead of directly mutating constants or ENV, use Rails’ built-in testing helpers or mocking libraries.

For example, do not do this:

# Avoid this pattern
test "calculates correct tax rate" do
  ENV['TAX_RATE'] = '0.05'
  assert_equal 5, TaxCalculator.calculate(100)
end

Instead, use stubbing to isolate the behavior:

# Prefer stubbing
test "calculates correct tax rate" do
  ENV.stub(:[], '0.05') do
    assert_equal 5, TaxCalculator.calculate(100)
  end
end

3. Handle Database Transaction Collisions

Rails wraps each test in a database transaction, rolling it back when the test completes. This is highly effective for isolating database state. However, race conditions can still occur with database constraints, particularly unique indexes.

If two parallel workers attempt to create a record with the same hardcoded unique attribute (e.g., an email address or a specific ID), the database might raise an ActiveRecord::RecordNotUnique error, or a Deadlock exception depending on the database adapter.

To resolve this, ensure your test factories or fixtures generate truly unique data. If you are using FactoryBot, leverage sequences:

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
  end
end

When you avoid hardcoded unique values, parallel test workers can insert records simultaneously without colliding at the database level.

4. Rethink Class-Level Memoization

Memoization is a standard technique for improving performance by caching the results of expensive operations. However, class-level memoization stores state globally across the application.

class PaymentGateway
  def self.client
    @client ||= ExternalAPI::Client.new
  end
end

If one test modifies the behavior of @client (e.g., by stubbing a method on it), that change leaks into other tests running in the same process or thread.

To fix this, you must clear the memoized state between tests. You can add a teardown block in your test suite, or better yet, refactor the application code to avoid class-level state where possible, perhaps by injecting the dependency instead.

class ActiveSupport::TestCase
  teardown do
    PaymentGateway.instance_variable_set(:@client, nil) if PaymentGateway.instance_variable_defined?(:@client)
  end
end

Maintaining Technical Health

Fixing race conditions in Minitest provides immediate benefits by passing the CI build today, while ensuring the long-term technical health of your application. A test suite that fails randomly destroys developer trust and slows down feature delivery.

When executing a Ruby and Rails upgrade, treat test stabilization as a core requirement, not an afterthought. By isolating external services, preventing global state mutation, avoiding database collisions, and managing memoization, you build a durable, reliable test suite capable of supporting your application 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