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
endParallel 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:
- Creates databases named
app_test_0,app_test_1,app_test_2,app_test_3 - Runs migrations on each
- Assigns each worker process to its own database
The database setup is triggered by:
bin/rails db:test:prepareOr 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
endUsing 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
endWith 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
endParallel 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
endFor 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
endMeasuring 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 testTypical 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)
endCI 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 automaticallyControlling 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:
- Class-level variables — each process gets its own copy (safe), but mutations within a test leak to subsequent tests in the same process
- External files — two workers writing to the same temp file
- 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.rbIf 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.