Reading Ruby - Minitest's Plugin System

Author: Adam Sanderson Published: 2015-04-03

This article references [Minitest 5.5.1](https://github.com/seattlerb/minitest/tree/7298fce695b7a386392a293f23e6253576b05473). Some code snippets have been abbreviated for clarity.

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")

Follow along:

`qw minitest`

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.

`:nodoc:` means extra fun time!

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.

Should `seen` be a Set or maybe an Array? [It probably doesn't matter](https://gist.github.com/adamsanderson/47974e50c345b94cacac).

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:

Read up on [OptionParser](http://ruby-doc.org/stdlib-2.2.1/libdoc/optparse/rdoc/OptionParser.html), and you won't need to invent your own.

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.

Plugins are found by a file naming convention.

Plugins are configured and initialized with method naming conventions.

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

defined? can be used to check for variables

can be used to check for variables File.basename takes a second argument that removes extensions

takes a second argument that removes extensions CompositeReporter implements the Composite Pattern

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

Please enable JavaScript to view the comments powered by Disqus.

Disqus