Have you ever wondered what happens when you run a Minitest test suite? How does it work?
This article will demystify Minitest’s magic by going deep into its source code. After reading it, you’ll no longer consider Minitest a magical black box and will understand how Minitest discovers your tests methods and executes them.
Although the standard programming wisdom instructs us to avoid it, reinventing the wheel is a great way to learn the basic principles about the wheel.
So how does Minitest work?
Let’s write a simplest code possible that demonstrates how it’s used:
require "minitest/autorun"
class MathTest < Minitest::Test
def test_two_plus_two
assert 2 + 2 == 4
end
end
When we run it, we get the following output:
Run options: --seed 22395
# Running:
.
Finished in 0.000802s, 1246.8827 runs/s, 1246.8827 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Let’s try to figure out how it works by removing the require
line. If we run it without the require, we’ll get the following error:
Traceback (most recent call last):
test.rb:3:in `<main>': uninitialized constant Minitest (NameError)
It looks like we’re going to need to define that class, so let’s do that:
module Minitest
class Test; end
end
class MathTest < Minitest::Test
def test_two_plus_two
assert 2 + 2 == 4
end
endIf we run it, absolutely nothing will happen, but at least it will not blow up.
The next step is actually running the test. We need to do the following:
test_In order to do 1, we need to add some sort of descendant tracking
to our Test class. Let’s add it:
module Minitest
class Test
def self.inherited(klass)
@descendants ||= []
@descendants << klass
end
def self.descendants
@descendants || []
end
end
end
class MathTest < Minitest::Test
def test_two_plus_two
assert 2 + 2 == 4
end
endNow we’re ready for 2, so let’s call the methods that start with test_.
Since these methods are instance methods, it makes sense to instantiate
descendant classes we have tracked:
module Minitest
class Test
def self.inherited(klass)
@descendants ||= []
@descendants << klass
end
def self.descendants
@descendants || []
end
end
end
class MathTest < Minitest::Test
def test_two_plus_two
assert 2 + 2 == 4
end
end
Minitest::Test.descendants.each do |klass|
klass.new.tap do |instance|
instance.methods.grep(/^test_/).each do |method_name|
instance.public_send(method_name)
end
end
endIf we run this we’ll get another error:
Traceback (most recent call last):
6: from test.rb:22:in `<main>'
5: from test.rb:22:in `each'
4: from test.rb:24:in `block in <main>'
3: from test.rb:24:in `each'
2: from test.rb:25:in `block (2 levels) in <main>'
1: from test.rb:25:in `public_send'
test.rb:18:in `test_two_plus_two': undefined method `assert' for #<MathTest:0x00007f8eeb0f4ef8> (NoMethodError)
This is just what we’ve expected since we didn’t define
the assert method yet. Let’s add it:
module Minitest
class Test
def self.inherited(klass)
@descendants ||= []
@descendants << klass
end
def self.descendants
@descendants || []
end
def assert(condition)
print '.' if condition
end
end
end
class MathTest < Minitest::Test
def test_two_plus_two
assert 2 + 2 == 4
end
end
Minitest::Test.descendants.each do |klass|
klass.new.tap do |instance|
instance.methods.grep(/^test_/).each do |method_name|
instance.public_send(method_name)
end
end
endRun it and witness the dot that is printed out in all its glory:
.
So far so good, but we need to report the number of runs, assertions and failures, in addition to dots.
Let’s count the number of assertions:
module Minitest
class Test
def initialize
@assertions_count = 0
end
attr_reader :assertions_count
def self.inherited(klass)
@descendants ||= []
@descendants << klass
end
def self.descendants
@descendants || []
end
def assert(condition)
@assertions_count += 1
print '.' if condition
end
end
end
class MathTest < Minitest::Test
def test_two_plus_two
assert 2 + 2 == 4
end
end
Minitest::Test.descendants.each do |klass|
klass.new.tap do |instance|
instance.methods.grep(/^test_/).each do |method_name|
instance.public_send(method_name)
end
end
endNice, we’re now counting the assertions. The only problem now is printing it. Let’s start printing a simple report:
module Minitest
class Test
def initialize
@assertions_count = 0
end
attr_reader :assertions_count
def self.inherited(klass)
@descendants ||= []
@descendants << klass
end
def self.descendants
@descendants || []
end
def assert(condition)
@assertions_count += 1
print '.' if condition
end
end
end
class MathTest < Minitest::Test
def test_two_plus_two
assert 2 + 2 == 4
end
end
Minitest::Test.descendants.map do |klass|
klass.new.tap do |instance|
instance.methods.grep(/^test_/).each do |method_name|
instance.public_send(method_name)
end
end
end.each_with_object(runs: 0, assertions: 0) do |instance, counter|
counter[:assertions] += instance.assertions_count
counter[:runs] += instance.methods.grep(/^test_/).count
end.tap do |counter|
puts "\n\n#{counter[:runs]} runs, " \
"#{counter[:assertions]} assertions, " \
'0 failures, 0 errors, 0 skips'
endRun this code, and you’ll get the report printed out:
.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips
Great!
However, this is not precisely how Minitest works. Minitest doesn’t magically add code to the bottom of our tests to print its report. Remember, we require Minitest before our test code, so we need to keep our “test library” code before our tests.
We need to print the report when everything else has finished executing, and luckily Ruby comes with the Kernel#at_exit method we can use for this:
module Minitest
class Test
def initialize
@assertions_count = 0
end
attr_reader :assertions_count
def self.inherited(klass)
@descendants ||= []
@descendants << klass
end
def self.descendants
@descendants || []
end
def assert(condition)
@assertions_count += 1
print '.' if condition
end
end
end
def do_the_wizardry
Minitest::Test.descendants.map do |klass|
klass.new.tap do |instance|
instance.methods.grep(/^test_/).each do |method_name|
instance.public_send(method_name)
end
end
end.each_with_object(runs: 0, assertions: 0) do |instance, counter|
counter[:assertions] += instance.assertions_count
counter[:runs] += instance.methods.grep(/^test_/).count
end.tap do |counter|
puts "\n\n#{counter[:runs]} runs, " \
"#{counter[:assertions]} assertions, " \
'0 failures, 0 errors, 0 skips'
end
end
at_exit { do_the_wizardry }
class MathTest < Minitest::Test
def test_two_plus_two
assert 2 + 2 == 4
end
endExcellent! Our library code is now above the test code, which is also how Minitest works and does its magic.
Since we’re now total experts when it comes to test libraries, we are no longer afraid to look under the hood of Minitest[1].
git clone https://github.com/seattlerb/minitest.git
cd minitest
git checkout 1f2b132
Our assumption is that at_exit is used somewhere,
to print the report we get when running our test suite.
Let’s confirm it:
grep at_exit -nR .
This will bring us to:
def self.autorun
at_exit {
next if $! and not ($!.kind_of? SystemExit and $!.success?)
exit_code = nil
at_exit {
@@after_run.reverse_each(&:call)
exit exit_code || false
}
exit_code = Minitest.run ARGV
} unless @@installed_at_exit
@@installed_at_exit = true
endWhich uses at_exit to instruct Minitest to run after all the other code has been executed and the program is exiting. The first line skips invoking Minitest if
the exception is raised and it’s not SystemExit with zero status. [2]
This hook is only set up if @@installed_at_exit has not been set, ensuring
the hook will only be set up once. This allows requiring minitest/autorun multiple
times and not having to worry about what will happen with the at_exit hook (imagine
multiple test files each requiring minitest/autorun).
This brings us to the inside of the block:
def self.autorun
at_exit {
next if $! and not ($!.kind_of? SystemExit and $!.success?)
exit_code = nil
at_exit {
@@after_run.reverse_each(&:call)
exit exit_code || false
}
exit_code = Minitest.run ARGV
} unless @@installed_at_exit
@@installed_at_exit = true
endNotice the little trick with exit_code being set to nil first, before
assigning it to the result of Minitest.run. This is used to ensure that
the at_exit block runs regardless of the result of Minitest.run.[3]
This second at_exit hook will be executed after Minitest finishes its execution,
so this is setting up the second layer of code that’s going to be run
when the program is exiting.
In its block @@after_run is being called in reverse order. Where is it coming from?
module Minitest
VERSION = "5.11.3" # :nodoc:
ENCS = "".respond_to? :encoding # :nodoc:
@@installed_at_exit ||= false
@@after_run = []Ah, an ordinary array, but we’re calling .call on its items. What does it store?
##
# A simple hook allowing you to run a block of code after everything
# is done running. Eg:
#
# Minitest.after_run { p $debugging_info }
def self.after_run &block
@@after_run << block
endNice, so this is how Minitest keeps track of its after_run callbacks, it appends blocks
passed to Minitest.after_run to an ordinary array - nothing magical.
Now that we’ve demystified other parts of the at_exit hook that Minitest uses
to deploy its magic, let’s take a look at the one thing that’s left:
def self.autorun
at_exit {
next if $! and not ($!.kind_of? SystemExit and $!.success?)
exit_code = nil
at_exit {
@@after_run.reverse_each(&:call)
exit exit_code || false
}
exit_code = Minitest.run ARGV
} unless @@installed_at_exit
@@installed_at_exit = true
endThis is the most important line of that method since it actually runs the tests. Let’s dig into it!
Searching for it, we discover these methods:
##
# This is the top-level run method. Everything starts from here. It
# tells each Runnable sub-class to run, and each of those are
# responsible for doing whatever they do.
#
# The overall structure of a run looks like this:
#
# Minitest.autorun
# Minitest.run(args)
# Minitest.__run(reporter, options)
# Runnable.runnables.each
# runnable.run(reporter, options)
# self.runnable_methods.each
# self.run_one_method(self, runnable_method, reporter)
# Minitest.run_one_method(klass, runnable_method)
# klass.new(runnable_method).run
def self.run args = []
self.load_plugins unless args.delete("--no-plugins") || ENV["MT_NO_PLUGINS"]
options = process_args args
reporter = CompositeReporter.new
reporter << SummaryReporter.new(options[:io], options)
reporter << ProgressReporter.new(options[:io], options)
self.reporter = reporter # this makes it available to plugins
self.init_plugins options
self.reporter = nil # runnables shouldn't depend on the reporter, ever
self.parallel_executor.start if parallel_executor.respond_to?(:start)
reporter.start
begin
__run reporter, options
rescue Interrupt
warn "Interrupted. Exiting..."
end
self.parallel_executor.shutdown
reporter.report
reporter.passed?
end##
# Internal run method. Responsible for telling all Runnable
# sub-classes to run.
def self.__run reporter, options
suites = Runnable.runnables.
reject { |s| s.runnable_methods.empty? }.
shuffle
parallel, serial = suites.partition { |s| s.test_order == :parallel }
serial.map { |suite| suite.run reporter, options } +
parallel.map { |suite| suite.run reporter, options }
endSo far so good, but we’re little confused now about this Runnable.runnables invocation.
It looks like it’s an array, but where did it come from?
We track it down:
##
# Returns all subclasses of Runnable.
def self.runnables
@@runnables
endThis doesn’t clear things up, so we keep searching and find this code:
class Runnable # re-open
def self.inherited klass # :nodoc:
self.runnables << klass
super
end
endAh! It uses the Class#inherited to track all
the classes that have inherited Runnable. It’s a bit weird to discover that @@runnables is being
appended to, but not assigned to an array yet. Here’s the missing piece:
def self.reset # :nodoc:
@@runnables = []
end
resetThis clears things up about Runnable.runnables being an array of classes that inherit
Runnable, but we still know nothing about the nature of those classes.
Doing a quick grep for < Runnable gives us a suspect:
class Result < RunnableRemember that we’re still stuck at this line:
##
# Internal run method. Responsible for telling all Runnable
# sub-classes to run.
def self.__run reporter, options
suites = Runnable.runnables.
reject { |s| s.runnable_methods.empty? }.
shuffle
parallel, serial = suites.partition { |s| s.test_order == :parallel }
serial.map { |suite| suite.run reporter, options } +
parallel.map { |suite| suite.run reporter, options }
endBut we now know (or do we, cough, cough?) that Runnable.runnables is [Result]. Looks like
we’re rejecting classes from that array that have
empty runnable_methods.
Tracking this method down leads us to the dead end:
##
# Each subclass of Runnable is responsible for overriding this
# method to return all runnable methods. See #methods_matching.
def self.runnable_methods
raise NotImplementedError, "subclass responsibility"
endTurns out, this nifty re-opening
of Runnable class has a purpose. The Runnable.inherited is defined
on line 977,
while Result < Runnable happens on line 496. Looks like the order is important here. Who knew!?
# This happens first
class Runnable
def self.reset # :nodoc:
@@runnables = []
end
reset
def self.runnables
@@runnables
end
end
class Result < Runnable; end
# And then we re-open the class
class Runnable # re-open
def self.inherited klass # :nodoc:
self.runnables << klass
super
end
end
# > Runnable.runnables
# => []Our suspect is free to go. We have found another one:
class Test < RunnableInteresting. But where do we even require that class?
require "minitest/test"Ah! That’s the last line of that file, so the inherited will kick in and catch this class. This suspect is now confirmed, and
we can happily declare that Runnable.runnables contains [Test].
We now track down Test.runnable_methods:
##
# Returns all instance methods starting with "test_". Based on
# #test_order, the methods are either sorted, randomized
# (default), or run in parallel.
def self.runnable_methods
methods = methods_matching(/^test_/)
case self.test_order
when :random, :parallel then
max = methods.size
methods.sort.sort_by { rand max }
when :alpha, :sorted then
methods.sort
else
raise "Unknown test_order: #{self.test_order.inspect}"
end
endThis returns all the instance methods of this class, that start with test_ by
using the Runnable#methods_matching:
##
# Returns all instance methods matching the pattern +re+.
def self.methods_matching re
public_instance_methods(true).grep(re).map(&:to_s)
endWe can now finally move a couple of lines:
##
# Internal run method. Responsible for telling all Runnable
# sub-classes to run.
def self.__run reporter, options
suites = Runnable.runnables.
reject { |s| s.runnable_methods.empty? }.
shuffle
parallel, serial = suites.partition { |s| s.test_order == :parallel }
serial.map { |suite| suite.run reporter, options } +
parallel.map { |suite| suite.run reporter, options }
endIt looks like some partitioning is happening, but we don’t care because:
##
# Defines the order to run tests (:random by default). Override
# this or use a convenience method to change it for your tests.
def self.test_order
:random
endWhich will put everything in serial (parallel will be empty array) and this leads us to:
##
# Internal run method. Responsible for telling all Runnable
# sub-classes to run.
def self.__run reporter, options
suites = Runnable.runnables.
reject { |s| s.runnable_methods.empty? }.
shuffle
parallel, serial = suites.partition { |s| s.test_order == :parallel }
serial.map { |suite| suite.run reporter, options } +
parallel.map { |suite| suite.run reporter, options }
endWhich finally calls Test.run, which Test inherits from Runnable:
##
# Responsible for running all runnable methods in a given class,
# each in its own instance. Each instance is passed to the
# reporter to record.
def self.run reporter, options = {}
filter = options[:filter] || "/./"
filter = Regexp.new $1 if filter =~ %r%/(.*)/%
filtered_methods = self.runnable_methods.find_all { |m|
filter === m || filter === "#{self}##{m}"
}
exclude = options[:exclude]
exclude = Regexp.new $1 if exclude =~ %r%/(.*)/%
filtered_methods.delete_if { |m|
exclude === m || exclude === "#{self}##{m}"
}
return if filtered_methods.empty?
with_info_handler reporter do
filtered_methods.each do |method_name|
run_one_method self, method_name, reporter
end
end
endThis filters methods by the --name and --exclude options
provided from the command line. Let’s get to the juicy part:
##
# Responsible for running all runnable methods in a given class,
# each in its own instance. Each instance is passed to the
# reporter to record.
def self.run reporter, options = {}
filter = options[:filter] || "/./"
filter = Regexp.new $1 if filter =~ %r%/(.*)/%
filtered_methods = self.runnable_methods.find_all { |m|
filter === m || filter === "#{self}##{m}"
}
exclude = options[:exclude]
exclude = Regexp.new $1 if exclude =~ %r%/(.*)/%
filtered_methods.delete_if { |m|
exclude === m || exclude === "#{self}##{m}"
}
return if filtered_methods.empty?
with_info_handler reporter do
filtered_methods.each do |method_name|
run_one_method self, method_name, reporter
end
end
endWhich brings us to Runnable.run_one_method:
##
# Runs a single method and has the reporter record the result.
# This was considered internal API but is factored out of run so
# that subclasses can specialize the running of an individual
# test. See Minitest::ParallelTest::ClassMethods for an example.
def self.run_one_method klass, method_name, reporter
reporter.prerecord klass, method_name
reporter.record Minitest.run_one_method(klass, method_name)
endWhich passes the potato to Minitest.run_one_method:
def self.run_one_method klass, method_name # :nodoc:
result = klass.new(method_name).run
raise "#{klass}#run _must_ return a Result" unless Result === result
result
endRemember that klass here is Minitest::Test and method_name is each
method defined in that class that starts with test_ and has survived
filtering.
Let’s repeat this line since it’s perhaps the most elegant line in the entire project.
def self.run_one_method klass, method_name # :nodoc:
result = klass.new(method_name).run
raise "#{klass}#run _must_ return a Result" unless Result === result
result
endThis line creates a new instance of Minitest::Test class and passes the current method name as the first argument. Minitest::Test inherits initialize from Minitest::Runnable which looks like this:
def initialize name # :nodoc:
self.name = name
self.failures = []
self.assertions = 0
endSo every test method we’ve created in our original test file gets its own Minitest::Test instance, which stores the name, failures, and number of assertions. A much better approach, compared to our hack from the beginning, but there are some similarities.
We still have one layer before we reach the end since we’re calling
run on an instance of Minitest::Test.
Here is that final layer in its entirety:
##
# Runs a single test with setup/teardown hooks.
def run
with_info_handler do
time_it do
capture_exceptions do
before_setup; setup; after_setup
self.send self.name
end
TEARDOWN_METHODS.each do |hook|
capture_exceptions do
self.send hook
end
end
end
end
Result.from self # per contract
endLet’s highlight the most important line in that method.
##
# Runs a single test with setup/teardown hooks.
def run
with_info_handler do
time_it do
capture_exceptions do
before_setup; setup; after_setup
self.send self.name
end
TEARDOWN_METHODS.each do |hook|
capture_exceptions do
self.send hook
end
end
end
end
Result.from self # per contract
endThis calls our original test method named test_two_plus_two. The name was stored in Minitest::Runnable#initialize, as explained earlier.
Other parts of this method are pretty self-explanatory, and I will not go into details. The goal of this article is achieved since we have tracked down and found out exactly what happens when we run our Minitest tests.
Hooray!
The commit 1f2b132 is no longer the latest commit in the repository. The first draft of this article was written 8 weeks ago, and I apologize for not publishing it sooner.
This means it will
be skipped for any exceptions except exit 0 which raises SystemExit with success?
set to true. You can test this with the following code:
require 'minitest/autorun'
exit 0
Which will result in the standard Minitest report being printed out. Try replacing the exit status like this:
require 'minitest/autorun'
exit 1
And notice that the Minitest report was not printed out. The same thing happens if you raise an exception.
The simplest example to confirm this behavior is:
exit_code = nil
at_exit do
puts 'inside at_exit'
exit exit_code
end
exit_code = raise
Compare that with:
exit_code = raise
at_exit do
puts 'inside at_exit'
exit exit_code
end