skip to content

To speed up a 20-minute Minitest suite with parallelize_me!, what does it actually parallelise, how does MT_CPU size it, and which tests break?

level: seniorimportance: should knowfreq 35%

answer

  1. threads in one process, not forks
  2. MT_CPU, else Etc.nprocessors
  3. ENV["N"] removed in 6.0
  4. parallel classes run after serial ones
  5. global stubs, ENV, Dir.chdir break

basics

~20 s

parallelize_me! runs that class's tests on a pool of threads in the same process, sized by MT_CPU or the CPU count. Under CRuby's GVL it mainly speeds up tests that wait on I/O, and it breaks tests that share process-wide state.

solid answer

~40 s

`parallelize_me!` marks a class (and its subclasses) with `run_order :parallel`; its tests are pushed onto a queue served by a pool of **threads** in the same process. The pool size is `MT_CPU`, else `Etc.nprocessors`; with a size of 1 no executor exists and `parallelize_me!` does nothing. Minitest 6 removed the old `N` variable for this. Parallel classes run after all serial ones, and reporting is synchronised. On CRuby only one thread runs Ruby code at a time, so a CPU-bound suite gains little; tests that wait on a database, HTTP or `sleep` gain most. What breaks is anything process-wide: class-method stubs such as `Time.stub(:now)`, `ENV` edits, `Dir.chdir`, globals and class-level caches, shared database rows, and `Minitest::Mock` across threads. Measure first (`rake test:slow`), then parallelise classes proven isolated.

code

bash · 4 lines
bash
rake test:slow                 # the 25 slowest tests
MT_CPU=8 rake test             # size the thread pool
MT_CPU=1 rake test             # disable parallelism to compare
MT_HELL=1 rake test            # parallelise every class to probe isolation

go deeper

for a junior

Recall that parallelize_me! goes inside a test class and that MT_CPU sets how many threads run its tests.

for a middle

Explain the thread pool and queue, serial-before-parallel scheduling, MT_CPU versus the removed N, and why the GVL limits CPU-bound gains.

for a senior

Lead the speed-up: measure with test:slow, remove global stubs and shared state, parallelise isolated classes, and use MT_CPU=1 to bisect regressions.

for a principal

Choose between threads, process-level splitting and fixing slow tests for the team's suite, weighing CI cost, flakiness risk and maintenance.

## Start with measurement A 20-minute suite is rarely slow evenly. Before parallelising, find where the time goes: - `rake test:slow` (from `Minitest::TestTask`) runs the suite verbosely and prints the 25 slowest tests. - `-v` on any run prints each test with its duration. - `rake test:isolated` runs each file alone and shows which ones fail in isolation, a hint about hidden coupling that parallelism will also expose. Often a few classes that sleep, call real HTTP or rebuild big fixtures account for most of the time, and fixing them beats any runner setting. ## What `parallelize_me!` does ```ruby class RemoteFineLookupTest < Minitest::Test parallelize_me! # ... end ``` 1. When Minitest loads it reads `MT_CPU`, falling back to `Etc.nprocessors`, and creates a `Minitest::Parallel::Executor` with that many worker threads if the number is greater than 1. 2. `parallelize_me!` returns immediately if there is no executor. Otherwise it makes the class's `run_order` `:parallel` and replaces how its tests are scheduled: each test is pushed onto a `Thread::Queue`. 3. The workers pop jobs and run each test on a fresh instance, recording results under a lock. 4. `run_all_suites` runs all **serial** classes first and the **parallel** ones afterwards, so serial tests never overlap with threaded ones. Calling it on `Minitest::Test` itself, or requiring `minitest/hell` (or setting `MT_HELL`), parallelises every class, which is a quick way to find out how isolated a suite really is. Minitest 6.0 removed the deprecated `ENV["N"]` for the thread count; `MT_CPU` is the only knob. `MT_CPU=1` switches parallelism off without editing code. ## Why threads help less than expected The executor uses threads, not processes. On CRuby a thread must hold the Global VM Lock to run Ruby code, and it releases it while blocked on I/O. So: | test workload | expected gain | |---|---| | waits on a database, a socket or `sleep` | large: other threads run meanwhile | | pure Ruby computation | little or none: threads take turns | | mixed | somewhere in between; measure it | Minitest itself has no process-based parallel mode. Spreading a CPU-bound suite across cores means several processes, each running a slice of the files. ## What breaks under parallelism Everything that is shared by the whole process is now shared by concurrently running tests: - **Class-method stubs.** `Time.stub(:now, t)` replaces `now` on `Time` for every thread during the block. - **`Minitest::Mock`** is documented by minitest-mock as not supporting multi-threading. - **`ENV` changes, `Dir.chdir`**, globals and class-level caches or configuration. - **Output capture** with `capture_io`, which swaps the process-wide `$stdout` and can pick up another thread's output. - **Shared external state**: the same database rows, files at fixed paths, fixed ports. - **Output assertions** that expect only their own test's output. The classic symptom is a suite that passes with `MT_CPU=1` and fails intermittently with more threads. ## A rollout plan 1. Measure and fix the slowest tests first. 2. Make isolation the default: temp dirs per test, injected clocks instead of `Time.stub`, unique records per test. 3. Add `parallelize_me!` class by class, starting with I/O-heavy, well-isolated classes. 4. Run several seeds in CI after each step; a failure that vanishes with `MT_CPU=1` points at shared state. 5. Keep a way out: `MT_CPU=1` in the environment turns parallelism off while you investigate.

  • With Minitest, a suite passes with MT_CPU=1 but fails randomly with MT_CPU=8. Where do you look?
    At process-wide state touched by the parallel classes: class-method stubs such as `Time.stub(:now)`, `ENV` edits, `Dir.chdir`, globals and class-level caches, fixed file paths or ports, shared database rows, and mocks called across threads. Run the failing seed again, then check which parallel tests overlapped with the failing one.
  • With Minitest on CRuby, why may parallelize_me! barely speed up a CPU-heavy test class?
    The executor runs tests on threads within one process, and on CRuby only the thread holding the Global VM Lock executes Ruby code. Pure computation therefore takes turns; the gain comes from tests that block on I/O and release the lock. CPU-bound suites need several processes instead.

saying these in an interview costs you the question

  • parallelize_me! forks a process per CPU core.
  • N=8 rake test sets the Minitest 6 thread count.
  • Parallel tests on CRuby run Ruby code on all cores at once.
  • Time.stub(:now) inside one parallel test only affects that test.
  • parallelize_me! still runs tests in parallel when MT_CPU=1.