2 CPUs = 2× testing: parallelising your RSpec suite
Your test suite runs on one core while the other three sit idle. A thorough guide to splitting RSpec across processes with parallel_tests — separate databases, runtime-balanced groups, the isolation bugs it surfaces, and scaling it out across CI nodes.
Here is a slightly embarrassing fact about most Rails test suites: they run on a single core while the rest of your machine watches. You bought a quad-core laptop and your specs use roughly 25% of it. Meanwhile the suite has crept from two minutes to eight, and a slow suite is a suite people stop running before they push — which quietly erodes the entire point of having one.
The fix is to fan the suite out across processes, one per core, each with its own
database. The parallel_tests gem
makes this almost mechanical. “Almost” is carrying weight in that sentence,
because parallelism does not introduce new bugs so much as it promotes a class
of latent ones — order-dependence and shared-state leaks — from “passes on my
machine” to “fails intermittently in CI”. So here is the full picture from setting
it up on real suites: the mechanics, the one tuning knob that matters, and the
bugs you will flush out along the way.
Why not just use threads?
The obvious question first. Ruby has threads; why spin up whole processes? Two
reasons. The MRI global interpreter lock means CPU-bound Ruby does not actually
run in parallel across threads — and a test suite is CPU-bound. More
fundamentally, your tests share mutable global state that was never designed to be
touched concurrently: the database above all, but also class-level memoization,
Time stubs, the loaded fixtures, temp files. Threads would share all of it and
trample each other. Separate OS processes get separate memory and, with a little
setup, separate databases — true isolation. The cost is process startup overhead,
which is why you get a strong speed-up but never a perfectly linear one.
The core idea: one process, one database
The loudest piece of shared state is the database, so the model is: one process
per CPU, and each process gets its own database — app_test, app_test2,
app_test3, and so on. parallel_tests coordinates this with an environment
variable, TEST_ENV_NUMBER, that each worker can read. You wire it into
database.yml:
test:
adapter: postgresql
database: app_test<%= ENV['TEST_ENV_NUMBER'] %>
pool: 5
The first worker sees an empty TEST_ENV_NUMBER (so, app_test), the second sees
2 (app_test2), and so on. Then you create and load schema into all of them at
once:
rake parallel:create
rake parallel:prepare # loads schema into every test database
That parallel:prepare step is the one people forget, and the resulting failure
is baffling: you add a migration, run db:migrate against app_test only, your
specs pass when you run them serially, then half of them fail under
parallel:spec because workers 2–4 are pointing at databases with an old schema.
Make parallel:prepare part of your “after pulling main” routine and the problem
disappears.
Running it
With the databases in place, the runner splits your spec files into groups, one per worker:
rake parallel:spec # uses all cores
rake parallel:spec[4] # force 4 workers
On a four-core machine an eight-minute suite typically drops to two to three minutes. Not a clean 4× — and it is worth understanding why, because it tells you where the remaining time goes. There is a fixed per-worker cost (booting Rails four times instead of once), and there is Amdahl’s law: the suite finishes only when the slowest group finishes, so any imbalance between groups is wasted cores. That second factor is entirely in your control, and it is the single most useful thing to tune.
Balance by runtime, not by file count
By default the gem splits files into groups of roughly equal count. That is the wrong metric. One slow feature spec full of Capybara browser interactions can take longer than fifty fast model specs combined. If that monster lands alone in group one, three workers finish in 40 seconds and the fourth grinds on for two minutes — and your wall-clock time is set by that straggler while three cores sit idle.
The fix is to balance by recorded runtime. Run the suite once to generate the log, then tell the splitter to pack groups by time:
rake parallel:spec # writes tmp/parallel_runtime_rspec.log
rake "parallel:spec[,, --group-by runtime]"
Once that log exists, the groups self-level: the one slow spec gets a group nearly to itself, and the fast specs fill the others to match. Persist the log (commit it, or cache it in CI) so the balancing survives a fresh checkout. This single change routinely buys more wall-clock time than adding another core would.
The gotcha: tests that were never really isolated
This is the part nobody warns you about. Parallelism does not create bugs; it exposes the ones already lurking in specs that quietly depend on each other or on shared resources. Expect to spend your first afternoon fixing these — and be glad, because they were real bugs.
Shared external resources. Two workers writing to the same file in tmp/,
both binding the same fake SMTP or Capybara server port, both seeding the same
Redis database, both replaying the same VCR cassette. Namespace every such
resource by TEST_ENV_NUMBER exactly as you did the database:
RSpec.configure do |config|
config.before(:suite) do
n = ENV["TEST_ENV_NUMBER"].presence || "1"
Redis.current = Redis.new(db: n.to_i) # separate Redis db per worker
Capybara.server_port = 9887 + n.to_i # separate port per worker
end
end
DatabaseCleaner strategy. Within a single worker you still need clean state
between examples. Transactions are fastest, but a Capybara spec that runs the app
in a separate thread (a real browser driver) cannot see an uncommitted
transaction — so JavaScript feature specs need the :truncation strategy while
everything else uses :transaction. This is unchanged by parallelism, but
parallelism makes a misconfiguration fail intermittently, which turns a quick
fix into a long debugging session if you do not already understand the split.
Order dependence. A spec that passes only because an earlier spec left a row
behind will fail the instant the splitter puts the two in different workers. Run
your suite with --order random before you parallelise so you flush these out
first; otherwise you will be debugging a real isolation bug while also blaming the
new parallel setup. Each spec must build its own world and tear it down.
Interleaved output and artifacts. Logs from four workers interleave into an
unreadable mush, and screenshots or failure dumps from different workers can
clobber each other. Tag log lines and artifact filenames with TEST_ENV_NUMBER so
a failure is traceable to the worker that produced it.
CI: the bigger win
Locally, parallelism is a nice-to-have. In CI it is the difference between a fast feedback loop and a coffee break — and the same mechanism scales out across machines, not just cores. Split the suite into N groups and run each on its own build node, balancing by runtime so every node finishes at roughly the same time:
# node i of n
rake "parallel:spec[$CI_NODE_TOTAL,, --group-by runtime]" \
TEST_ENV_NUMBER=$CI_NODE_INDEX
Four build nodes turn a twenty-minute CI suite into a five-minute one, and
runtime-balancing keeps every node busy right to the end instead of one straggler
holding up the green checkmark while the others idle. The same approach works for
Cucumber (parallel:features) if you have a feature suite alongside your specs.
Was it worth it?
Unreservedly. The setup is an afternoon, most of which is spent fixing the two or three specs that were quietly depending on each other — and those were bugs you wanted to know about regardless. The payoff is a suite that uses the hardware you already paid for, runs in a quarter of the time, and therefore actually gets run before people push. The real speed of a test suite is not how fast it executes; it is how often it is allowed to. Parallelising it moves that dial more than almost anything else you can do in a single afternoon.