Build MiniTest from Scratch to Learn Ruby Metaprogramming

How does MiniTest work in Ruby? In this tutorial, we build our own version of MiniTest from scratch to show you how it works and learn about Ruby Metaprogramming.

Launch School
4 min readMay 29, 2021

Note: This article was originally published on the Launch School blog on 2015–12–23

Reading through source code is a great way to learn and level up as a programmer. However, I used to skim through code just to get to the more beautifully-written part of the project, without giving much consideration to the overall structure of the project and what holds everything together. Casually reading code also often doesn’t leave a lasting impression and helped me grow as a developer, because it wasn’t obvious what aspects I should work on to be as good as the author.

I recently decided to change how I read code: I will try to implement the project (or write down how I would implement the project) to build up the context before I read the source code. I did this with Ruby’s MiniTest project, and it’s a great learning experience.

The “Build to Learn” Process with MiniTest

Before I read the source code of a project, I start with the following general questions:

  • How do I provide abstractions?
  • How do I layer different concerns?
  • How do I organize the configuration files?
  • How do I implement functionality?

Since MiniTest is a testing framework, there are some specific questions:

  • How does it process signals?
  • How does it dispatch tasks into multiple processes?
  • How does it gather outputs from child processes to the master worker?

After making my own attempt to implement it, I read MiniTest’s source code to compare, and asked myself the following questions as part of the learning experience:

  • How did the author accomplish what I didn’t manage to implement?
  • Among those that I managed to implement, how did the author implement in a better way?

The biggest difference of learning this way is that there’s no middle ground between knowing and not knowing something anymore. Once the analysis is done, it’s obvious what my weaknesses are.

The Three Magic Implementations

Here is how I would design Minitest if I were to write it from scratch:

  1. Require test files (xx_test.rb).
  2. Collect all the subclasses of Minitest::Test.
  3. Collect all test methods and transform them to test jobs.
  4. Run tests and output results.

After the initial thoughts, I started to think about the details. There are 3 implementation questions that I couldn’t figure out how to (beautifully) implement.

This snippet defines a test class, after running ruby test_first.rb in a terminal, the process returns immediately.

# before.rb
class CompanyTest < Minitest::Test
def test_struct
assert_equal "SAP", Struct.new(:name).new("SAP").name
end
end

But if we add require "minitest/autorun" to the beginning, it becomes a test file.

# after.rb
gem "minitest"
require "minitest/autorun"
class CompanyTest < Minitest::Test
def test_struct
assert_equal "SAP", Struct.new(:name).new("SAP").name
end
end

If we run ruby after.rb, we can see find test result in the output.

$ ruby after.rb
Run options: --seed 3152
# Running:.Finished in 0.001035s, 966.3890 runs/s, 966.3890 assertions/s.1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

If you’re like me, you’ll be curious to know what Minitest sneaks into the code to make it happen:

  1. How did it collect all the test classes?
  2. When was the test job executed?
  3. How did it transform methods in ComapnyTest into runnable tests? (we'll discuss this in a future blog post)

With these questions, reading through MiniTest’s source code is now much more interesting!

How Does Minitest::Test Collect All Test Classes?

Minitest uses a Meta Programming Hook self.inherited to collect TestCase.

All Test cases are inherited from Minitest::Runnable. When you define test cases, it'll trigger the self.inherited hook, which puts all test case classes into the class variable @@runnables of Minitest::Runnable. All the written class files will be collected there.

module Minitest
class Runnable
def self.inherited klass
self.runnables << klass
super
end
end
end

FYI, common Ruby metaprogramming hooks include:

  • inherited, triggered when a sub-class is created
  • included, triggered when the module is included
  • extended, triggered when the module is extended

When Does It Run the Tests?

# after.rb
gem "minitest"
require "minitest/autorun"
class CompanyTest < Minitest::Test
def test_struct
assert_equal "SAP", Struct.new(:name).new("SAP").name
end
end

This code doesn’t have any logic related to run test but Manitest did it anyways. When exactly did it happen?

The answer is: when the process exited.

The Kernal#at_exit method defines what to do when a process exits. A lot of Ruby gems use the method. For example, Capybara uses it to closes the browser, Sinatra uses it to run the Application.

The documentation for Kernal#at_ext:

Converts block to a Proc object (and therefore binds it at the point of call) and registers it for execution when the program exits. If multiple handlers are registered, they are executed in reverse order of registration.

Here’s a simple example of at_exit

# exit.rbputs "step 1"at_exit do
puts "step 2"
end
puts "step 3"

The snippet outputs the following:

step 1
step 3
step 2

We can see that step 2 is executed last.

Minitest.autorun defines what to do when a process exits — running tests.

# Registers Minitest to run at process exit
def self.autorun
at_exit {
next if $! and not $!.kind_of? SystemExit
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
end

But this design is too obscure or too smart to be normal. It feels like writing the climax of a drama in the footnotes.

Reference

  1. Are we abusing at_exit?
  2. Hitchhiker’s Guide to Metaprogramming: Class/Module Hooks

--

--

Launch School

The slow path for studious beginners to a career in software development.