Among all the other morsels of sunshine and goodness it offers, Minitest gives you the ability to denote that some or all of your test cases are able to run concurrently, and as pointed out in the source comments, that can only mean one thing: that you rule and your tests are awesome.

I wanted to better understand how and when to use parallelization in my own code, so I dove into the source and ran some simple bench tests to come up with some guidelines for how to put it to work.

The Basics

There are two ways to switch on parallelization in your tests. You can apply it to all test cases in your suite by adding the following lines to your test_helper.rb file:

test/test_helper.rb
1
2
3
4
5
require 'minitest/hell'

class Minitest::Test
  parallelize_me!
end

The inclusion of Minitest::Hell ensures that the tests in all cases will be executed in random order by overriding Minitest::Test.test_order to return :parallel. (Random test ordering also happens to be the default behavior, but this can be overridden per-test case.) The call to parallelize_me! causes all test cases to queue tests across a pool of concurrent worker threads via the Minitest::Parallel::Executor helper class. The implementation is worth checking out because it’s super simple and does its job nicely - a really great example of restrained, focus code.

It’s also perfectly reasonable, though presumably somewhat less awesome, to call parallelize_me! only on certain individual test cases when some tests require execution in a strict order. It’s also possible to override global parallelization settings on individual test cases to require serial execution by calling i_suck_and_my_tests_are_order_dependent! within the test class.

One additional feature that’s not documented anywhere that I could find except in the source code: you can specify the number of worker threads started and run by Minitest::Parallel::Executor by providing an environment variable on the command line when you run your tests like so: N=4 rake test. (Minitest defaults to N=2 if no other value is given.)

Ruby Concurrency vs. Ruby Parallelism

There’s an important difference between these two concepts that needs to be made clear before moving forward: concurrency indicates an ability to manage several tasks, while parallelism implies that I can actually do multiple things simultaneously. (Ilya Grigorik summed this up better than I ever could in an article he wrote on the subject.) As an example, I’m might be able to juggle lots of chainsaws (excellent concurrency), but I probably can’t hold more than one of them at any given time (no parallelism).

In Ruby, whether you achieve parallelism or only concurrency depends on the thread model implemented by the interpreter. This is a decision that you the developer make. Minitest can’t influence it either way. Concretely, MRI’s thread model maps Ruby Thread objects onto system-level threads, but the Global Interpreter Lock (GIL) ensures thread safety by ensuring that only one is ever allowed to execute at any given time, so parallelism within a single Ruby process is impossible. Other Ruby implementations - JRuby and Rubinius, as examples - don’t have the GIL, and so true parallel execution is possible in both these environments.

Ryan Davis, the author of Minitest makes no secret about what Minitest::Parallel was written for:

I don’t care in the slightest about trying to make your tests run faster. You deserve your pain if you write slow tests. What I do care about greatly is making your tests hurt and this will do that.

- Ryan Davis

And he’s right, of course. Randomizing the order in which tests are executed gives you a certain measure of confidence that you’ve managed to write independent tests, and that’s the default behavior Minitest provides. But by then executing tests across multiple threads that don’t share state between, you’re adding an additional level of isolation insurance. But why shouldn’t we have all of that and the fastest possible test execution? Are there any trade-offs?

The Setup

I set up a base project that consisted of an empty Ruby gem with a basic Rakefile and code to generate 1000 tests spread over ten more or less identical test cases which all followed the same pattern:

test/one_fake_test.rb
1
2
3
4
5
6
7
8
9
10
require "test_helper"

class OneFakeTest < Minitest::Test
  (1..100).each do |i|
    define_method("test_#{ i }") do
      (1..8000).reduce(:*)
      assert(true)
    end
  end
end

My goal was to demonstrate test suite performance for three options:

  • Baseline - serial execution only
  • Minimally parallel - concurrent with two workers
  • Maximally parallel - concurrent with eight workers (= number of CPU threads on my development machine)

In order to see how Minitest behaved across different interpreters, I ran the same test using MRI Ruby 2.1.1, JRuby 1.7.9, and Rubinius 2.2.10.

For each implementation and each concurrency level, I executed three test runs and calculated the averages values for suite execution and number of assertions per second as reported by Minitest as well as the total and CPU times reported by the Unix time command. The tables below show the averages along with the relative differences over the baseline (serial) case in parentheses.

Start writing more effective tests.
Visit The Minitest Cookbook to sign up for regular updates and bonus content!
http://minitestcookbook.com

Round 1: MRI

MRI Ruby 2.1.1
Total Time (MT) Assertions/s (MT) Total Time
(UNIX time)
CPU Time
(UNIX time)
Serial
(baseline)
23.42s 42.71 24.06s 23.81s
Parallel
(2 threads)
26.99s
(15.28%)
37.07
(-13.20%)
27.65s
(14.95%)
27.39s
(15.02%)
Parallel
(8 threads)
29.99s
(28.09%)
33.34
(-21.93%)
30.66s
(27.45%)
30.18s
(26.74%)

The GIL optimizes for single-threaded execution, so while the additional concurrency might be giving us better assurances that each test is isolated and independent, it’s not free. It’s not too painful in this test application, but if I were running a suite for a large, well-covered Rails application where some slower tests are usually unavoidable, I might feel like the added insurance is too expensive.

Round 2: JRuby

MRI Ruby 2.1.1
Total Time (MT) Assertions/s (MT) Total Time
(UNIX time)
CPU Time
(UNIX time)
Serial
(baseline)
66.18s 15.11 72.12s 81.96s
Parallel
(2 threads)
40.35s
(-39.04%)
26.83
(77.53%)
46.45s
(-35.59%)
95.75s
(16.82%)
Parallel
(8 threads)
17.28s
(-73.90%)
58.02
(283.96%)
23.24s
(-67.77%)
134.28s
(63.83%)

My expectations for JRuby were higher because it uses a threading model that allows for fully parallel execution in multicore environments, and I was not disappointed. Despite being kind of a slow starter, JRuby was able utilize a lot more available CPU when I increased the concurrency, and I was able to run the test suite in about one-third the time.

Round 3: Rubinius

MRI Ruby 2.1.1
Total Time (MT) Assertions/s (MT) Total Time
(UNIX time)
CPU Time
(UNIX time)
Serial
(baseline)
24.31s 41.15 27.22s 28.80s
Parallel
(2 threads)
16.51s
(-32.07%)
60.61
(47.30%)
19.25s
(-29.29%)
35.14s
(22.02%)
Parallel
(8 threads)
12.81
(-47.31%)
78.10
(89.80%)
15.62s
(-42.62%)
63.42s
(120.18%)

Rubinius was the quickest interpreter I tested by a mile. Even though the performance gains from each additional thread weren’t as substantial as I saw in JRuby and tapered off more quickly, the fact is that Rubinius was really nimble, did a good job utilizing multiple cores, and needed about half the startup time of JRuby. I haven’t used Rubinius before, but this experience has convinced me to take a closer look at it in the future.

The Long and Short of It

In the end, I learned as much about Ruby and Ruby implementations as I did about Minitest, and so even though this all took some time to think through and set up, it was time well spent.

  • Concurrent execution in Minitest isn’t just (or even primarily) about speed. Even with no improvement in performance, the increased test isolation is a worthwhile benefit.
  • When using a Ruby implementation that allows for true parallel execution, enable parallel execution with concurrency equal to or approaching the number of available CPU cores on your test system.
  • When using MRI Ruby, you’ll see the best performance when running in a single thread, but taking a slight performance hit to run concurrently in two threads would probably be a good compromise.

YMMV when it comes to implementing (or not) concurrent execution within your own real-world tests, but hopefully this will give you a better understanding of how it works in Minitest, what you gain from it, and how to make it work for you.

Additional Resources

Time to level up your testing

The Minitest Cookbook

Frustrated with trying to learn about Minitest and the related ecosystem?

Whether you’re already an experienced tester or struggling to get started, The Minitest Cookbook has something for you.

Sign up here, and I’ll send you three chapters free along with regular Ruby and Rails development articles.

*
*
No spam ever. Unsubscribe at any time.

Comments