Reading Ruby - Minitest's Plugin System

We're going to read some of Minitest's source today, and see how it implements a simple, but flexible plugin system. We'll take a lot of breaks along the way to explore some interesting bits of Ruby as well.

Plugin Discovery

Typically if you want to add functionality to a library, you simply require, and perhaps configure it:

require "library"
require "library_extension"

Library.publish_url = LibraryExtension.new("http://example.com")

Minitest however is often used as a command line tool, which doesn't provide a good opportunity for this type of configuration. Instead, it needs to discover plugins automatically. Minitest achieves this with Minitest.load_plugins which is called right when Minitest first starts running.

def self.load_plugins # :nodoc:
  return unless self.extensions.empty?

  seen = {}

  require "rubygems" unless defined? Gem

  Gem.find_files("minitest/*_plugin.rb").each do |plugin_path|
    name = File.basename plugin_path, "_plugin.rb"

    next if seen[name]
    seen[name] = true

    require plugin_path
    self.extensions << name
  end
end

The first line is a guard clause, and prevents load_plugins from running twice. If you have one or more reasons to bail out of a function, this is nice idiomatic way to do it.

Minitest tracks all the plugins it's already loaded with seen = {}.

Next, it loads up RubyGems if it hasn't already loaded. As of Ruby 1.9 though, the RubyGems library is always preloaded, so this only affects older versions of Ruby.

The most interesting part about this is the use of defined?. Imagine it was written this way:

require "rubygems" unless Gem

This would raise an exception if Gem wasn't present. You can use defined? on anything in Ruby, but in practice you will usually only see it when accessing the object would have caused an exception if it wasn't present. There is one fun exception though:

defined? @x #=> nil
@x = 2
defined? @x #=> "instance-variable" 
@x = nil
defined? @x #=> "instance-variable" 

This behavior can be handy for memoization, but that's a whole other article.

Now for the real trick, Minitest uses RubyGem's file index to look for any files that match the pattern minitest/*_plugin.rb. This is how Minitest discovers plugins, even if they haven't been explicitly loaded.

This works really well for libraries that might optionally add support to a variety of other libraries which may or may not be present. For instance, a library that formats exceptions nicely might provide a plugin to integrate itself with Minitest. Using this pattern, the library does not require that Minitest be a dependency. An alternative might be to package a special gem for each library you might plug into.

Let's look at what Minitest does with these paths:

#...
name = File.basename plugin_path, "_plugin.rb"

next if seen[name]
seen[name] = true
#...

Calling File.basename will return the name of the file, without the file's path. If provided, File.basename will strip that off of the second argument from the file. For example:

File.basename "snail_plugin.rb"                            #=> "snail_plugin.rb"
File.basename "lib/minitest/snail_plugin.rb"               #=> "snail_plugin.rb"
File.basename "lib/minitest/snail_plugin.rb", "_plugin.rb" #=> "snail"

Minitest will use this later, but for now it just makes sure it hasn't seen this plugin already.

Finally, Minitest will actually require the plugin and add it to Minitest.extensions.

require plugin_path
self.extensions << name

At this point the plugin has been loaded, but nothing has really happened.

Plugin Configuration

Now let's see how those plugins fit into Minitest. Everything starts with Minitest.run:

def self.run args = []
  self.load_plugins

  options = process_args args

  reporter = CompositeReporter.new
  reporter << SummaryReporter.new(options[:io], options)
  reporter << ProgressReporter.new(options[:io], options)

  self.reporter = reporter
  self.init_plugins options
  # ...

As we saw above, plugins are discovered with load_plugins. Next Minitest configures its internal state by parsing command line arguments with process_args. This is where plugins can optionally introduce their own command line handling:

def self.process_args args = [] # :nodoc:
  options = {}

  #...

  OptionParser.new do |opts|

    #...

    unless extensions.empty?
      opts.separator ""
      opts.separator "Known extensions: #{extensions.join(', ')}"

      extensions.each do |meth|
        msg = "plugin_#{meth}_options"
        send msg, opts, options if self.respond_to?(msg)
      end
    end

    #...

  end

  options
end

Minitest gives each plugin a chance to register new options if it has defined a plugin_#{meth}_options, where meth is actually the name of the plugin. This is a great example using respond_to? and some simple conventions for feature detection.

Notice that Minitest calls these methods on self. This requires plugins to register behavior by reopening the Minitest module. I wonder if there's a cleaner solution, on the other hand as long as plugin names don't collide, this works just fine.

Back in Minitest.run, you'll see that Minitest uses those options used to set up the CompositeReporter reporter and two children:

reporter = CompositeReporter.new
reporter << SummaryReporter.new(options[:io], options)
reporter << ProgressReporter.new(options[:io], options)

This is a great example of the Composite Pattern if you're interested in learning more about design patterns.

Now Minitest is sufficiently configured, and is ready for its plugins to be initialized with init_plugins:

def self.init_plugins options # :nodoc:
  self.extensions.each do |name|
    msg = "plugin_#{name}_init"
    send msg, options if self.respond_to? msg
  end
end

This follows the same pattern that process_args did. After that, your tests start running.

Recap

Minitest has a simple, but effective plugin system that makes good use of simple naming conventions.

Aside from getting side tracked by some performance questions, we also came across a few interesting bits of code:

If you're curious, I've also built a simple plugin that you can use for reference: minitest-snail.

blog comments powered by Disqus
Monkey Small Crow Small