Rails Parallel Testing: Speed Up Your Test Suite Without Gems

Rails Parallel Testing: Speed Up Your Test Suite Without Gems

Since Rails 6, parallel test execution is built into the framework — no third-party gems required. One method call in your test helper can turn a 10-minute test suite into a 2-minute run. This guide covers the setup, database isolation, and the operational details that matter for CI.

Enabling Parallel Tests

Add parallelize to your test case base class:

# test/test_helper.rb (Minitest)
class ActiveSupport::TestCase
  parallelize(workers: :number_of_processors)
  parallelize_setup do |worker|
    # Runs once per worker process
  end
  parallelize_teardown do |worker|
    # Runs after each worker process finishes
  end
end
# spec/rails_helper.rb (RSpec with parallel_tests gem)
RSpec.configure do |config|
  # Built-in Rails parallelism works with RSpec via rails-parallel
end

Parallel Workers and Database Isolation

The key challenge with parallel testing: each worker needs its own database to avoid data collisions.

Rails handles this automatically. When you call parallelize(workers: 4), Rails:

  1. Creates databases named app_test_0, app_test_1, app_test_2, app_test_3
  2. Runs migrations on each
  3. Assigns each worker process to its own database

The database setup is triggered by:

bin/rails db:test:prepare

Or rails automates it when you run rails test with parallelism enabled.

Workers: Processes vs Threads

Rails supports two parallelism models:

Process-Based (Default)

Each worker is a separate OS process:

parallelize(workers: :number_of_processors)
# Equivalent to:
parallelize(workers: 4, with: :processes)

Process-based is safer because each process has completely isolated memory. No shared state, no thread-safety concerns. The tradeoff is startup overhead — each Rails app instance takes 500ms-2s to boot.

Thread-Based

All workers run in a single process using threads:

parallelize(workers: 4, with: :threads)

Thread-based is faster to start but requires your code to be thread-safe. Use this only if you know your application is thread-safe (it should be if you're running Puma in production).

Configuration

# test/test_helper.rb
class ActiveSupport::TestCase
  # Use all available processors in CI, 2 locally
  workers = ENV["CI"] ? :number_of_processors : 2
  parallelize(workers: workers)

  # Setup/teardown hooks run in each worker context
  parallelize_setup do |worker|
    # worker is an integer: 0, 1, 2, 3, ...
    # Seed database with fixtures if using static fixtures
    # (migrations are handled by Rails automatically)
  end

  parallelize_teardown do |worker|
    # Cleanup per-worker resources
  end
end

Using with DatabaseCleaner

If you use DatabaseCleaner instead of transactional fixtures:

# spec/support/database_cleaner.rb
RSpec.configure do |config|
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end

  config.around(:each) do |example|
    DatabaseCleaner.cleaning { example.run }
  end
end

With parallel processes, each process has its own database — DatabaseCleaner works normally because there's no cross-process sharing.

Fixtures and Parallel Tests

Rails fixtures are loaded once per worker database. If your tests depend on fixture data, that data must be present in each worker's database. Rails handles this automatically for parallelize(with: :processes).

class UserTest < ActiveSupport::TestCase
  fixtures :users, :accounts

  test "user has account" do
    # fixtures(:users) refers to the fixture data in this worker's database
    assert_equal accounts(:default), users(:alice).account
  end
end

Parallel Testing with FactoryBot

FactoryBot works fine with parallel processes — each process inserts into its own database. The sequence helper uses a shared counter that can cause sequence collisions with threads:

# Safe for processes, potentially unsafe for threads
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
  end
end

For thread-based parallelism, add a worker-specific prefix:

FactoryBot.define do
  factory :user do
    transient do
      worker_id { ENV.fetch("TEST_ENV_NUMBER", 0) }
    end

    sequence(:email) { |n| "user_w#{worker_id}_#{n}@example.com" }
  end
end

Measuring the Speedup

Benchmark before and after:

# Serial baseline
time rails test

# Parallel (uses :number_of_processors workers)
time rails test

# Force specific worker count
PARALLEL_WORKERS=4 time rails test

Typical results on an 8-core machine with a 500-test suite:

Workers Time
1 (serial) 180s
2 95s
4 55s
8 35s

Diminishing returns appear because some tests are fast (< 1ms) and parallel overhead dominates. I/O-bound tests (database, HTTP) see better parallelism gains than CPU-bound tests.

System Tests and Parallel

System tests (Capybara + Selenium) are slower and benefit most from parallelism. They also require special handling because each worker needs its own browser instance and a dedicated port:

# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]

  # Ensure each worker uses a different port for Capybara
  Capybara.server_port = 4444 + (ENV["TEST_ENV_NUMBER"].to_i)
end

CI Configuration

GitHub Actions with parallel Rails tests:

name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        options: --health-cmd pg_isready
        ports:
          - 5432:5432

    env:
      RAILS_ENV: test
      DATABASE_URL: postgresql://postgres:postgres@localhost/app_test
      CI: "true"

    steps:
      - uses: actions/checkout@v4

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3"
          bundler-cache: true

      - name: Create and migrate test databases
        run: |
          bin/rails db:create
          bin/rails db:test:prepare

      - name: Run tests
        run: bin/rails test
        # PARALLEL_WORKERS defaults to number of CPUs on the runner
        # GitHub Actions 2-core runners get 2 workers automatically

Controlling Worker Count

# Set explicitly
PARALLEL_WORKERS=8 rails test

# Disable parallelism for debugging
PARALLEL_WORKERS=1 rails test

# In test_helper.rb — use env var with fallback
parallelize(workers: Integer(ENV.fetch("PARALLEL_WORKERS", :number_of_processors)))

Identifying Parallel-Only Failures

Tests that pass serially but fail in parallel have shared state. Common culprits:

  1. Class-level variables — each process gets its own copy (safe), but mutations within a test leak to subsequent tests in the same process
  2. External files — two workers writing to the same temp file
  3. Shared cache — workers using the same Redis cache can interfere

Debug by forcing a single worker:

PARALLEL_WORKERS=1 rails test test/specific_failing_test.rb

If it passes with 1 worker but fails with 4, the test has isolation issues.

Summary

Rails parallel testing requires two things: parallelize(workers: N) in your test helper and separate databases per worker (Rails manages this automatically). The speedup is proportional to available CPU cores with near-linear scaling up to the point where I/O becomes the bottleneck. For most Rails test suites, enabling parallelism is the single highest-leverage change you can make — it's one line of code that turns a multi-minute CI run into something developers actually wait for.

Read more

Start now free