Ler blog em Português

Creating custom Minitest reporters

Read in 4 minutes

Minitest it’s having some momentum right now. I’ve been using it on some personal projects and, coming from RSpec, I really missed its awesome execution report. Minitest has some 3rd-party reporters, but they don’t get any close to RSpec’s awesomeness.

So I decided to dive into Minitest source code and learn how to create a custom Minitest reporter similar to RSpec’s reporter. Here’s the final result:

minitest-utils reporter: similar to RSpec's default reporter

Note that it even gives you the command for running only the test that failed, pretty much like RSpec. Sweet! By the way, if you want to have this reporter too, just install minitest-utils.

It turns out creating custom reporters in Minitest is a really simple task, so I’ll guide you in this article, creating two different reporters.

Understanding Minitest’s plugin system

Minitest’s plugin system relies on file discovery. It will load all files present on your $LOAD_PATH within the minitest/*_plugin.rb pattern. Let’s say I’m creating a gem called minitest-utils. I can create a file at lib/minitest/utils_plugin.rb and Minitest will load it. This file can implement two methods on Minitest module, as you can see below.

module Minitest
  def self.plugin_utils_init(options)
    # ...
  end

  def self.plugin_utils_options(opts, options)
    # ...
  end
end

Your methods have to follow the Minitest.plugin_*_init and Minitest.plugin_*_options naming scheme. Let’s breakdown these methods’ responsibility.

First, if you intend to extend the CLI, you have to implement the Minitest.plugin_*_options method. It receives two parameters containing the OptionParser instance and a hash containing options like the output IO instance. One could extend the CLI including options for displaying colored test output.

module Minitest
  def self.plugin_utils_options(opts, options)
    opts.on('--color', 'Display colored output') do |color|
      options[:color] = color
    end
  end
end

Minitest’s default reporter accepts multiple reporters, implemented as the Minitest::CompositeReporter class. For custom reporters, and other extensions, you have to implement the Minitest.plugin_*_init method, adding your reporter to the composite reporter.

module Minitest
  def self.plugin_utils_init(options)
    Minitest.reporter << GrowlReporter.new(options[:io], options)
  end
end

Now that you know how the plugin system works, let’s create a custom reporter.

Creating a custom reporter

Here’s how Minitest’s default reporter looks like.

Minitest default reporter

Not that useful. So, let’s make it shine with colors.

For this to work, we’ll need to clear out default reporters from the composite reporter. We can do it in the Minitest.plugin_utils_init method.

module Minitest
  def self.plugin_colored_reporter_init(options)
    Minitest.reporter.reporters.clear
    Minitest.reporter << ColoredReporter.new(options[:io], options)
  end
end

As you can see, we’re using a ColoredReporter class. Let’s define it.

class ColoredReporter < Minitest::StatisticsReporter
end

Now, you have to define the methods used for reporting. The first one is ColoredReporter#record, which is called after every test execution. This is the place we’ll output the realtime progress with all those dots and other failing indications.

class ColoredReporter < Minitest::StatisticsReporter
  def record(result)
    super

    result_code = result.result_code
    io.print color(result_code, RESULT_CODE_TO_COLOR[result_code])
  end
end

Note that we’re calling super; we have to do this so that Minitest::StatisticsReporter can compute the result status counting. Now, we have to implement the color method, which is pretty straight-forward.

class ColoredReporter < Minitest::StatisticsReporter
  RESULT_CODE_TO_COLOR = {
    'S' => :yellow,
    '.' => :green,
    'F' => :red,
    'E' => :red
  }

  COLOR_CODE = {
    red: 31,
    green: 32,
    yellow: 33,
    blue: 34,
    none: 0
  }

  def record(result)
    super

    result_code = result.result_code
    io.print color(result_code, RESULT_CODE_TO_COLOR[result_code])
  end

  def color(text, color = :none)
    code = COLOR_CODE[color]
    "\e[#{code}m#{text}\e[0m"
  end
end

Next, we have to implement the ColoredReporter#report method. This is where we show which tests failed, and can also display the statistics of the executed tests. In this case, we’ll basically call some methods.

class ColoredReporter < Minitest::StatisticsReporter
  # ...

  def report
    super

    io.puts "\n\n"
    io.puts statistics
    io.puts aggregated_results
    io.puts summary
  end
end

The ColoredReporter#statistics method will calculate the execution time, and some other info.

class ColoredReporter < Minitest::StatisticsReporter
  # ...

  def statistics
    "Finished in %.6fs, %.4f runs/s, %.4f assertions/s." %
      [total_time, count / total_time, assertions / total_time]
  end
end

The ColoredReporter#aggregated_results method will display the tests that failed or were skipped.

class ColoredReporter < Minitest::StatisticsReporter
  # ...

  def aggregated_results
    filtered_results = results.sort_by {|result| result.skipped? ? 1 : 0 }

    filtered_results.each_with_index.map { |result, i|
      color("\n%3d) %s" % [i+1, result], result.skipped? ? :yellow : :red)
    }.join + "\n"
  end
end

Finally, let’s display the test execution summary, with number of tests, assertions, failures, and more.

class ColoredReporter < Minitest::StatisticsReporter
  # ...

  def summary
    summary = "%d runs, %d assertions, %d failures, %d errors, %d skips" %
      [count, assertions, failures, errors, skips]

    color = :green
    color = :yellow if skips > 0
    color = :red if errors > 0 || failures > 0

    color(summary, color)
  end
end

And we’re done! Now we have more visibility about what’s happening in our tests, as you can see below.

Our custom reporter: way better!

One more example

What about displaying Growl notifications (or any other notification system you may have)? Using the test_notifier gem, this is quite simple.

class TestNotifierReporter < Minitest::StatisticsReporter
  def report
    super

    stats = TestNotifier::Stats.new(:minitest, {
      count: count,
      assertions: assertions,
      failures: failures,
      errors: errors
    })

    TestNotifier.notify(status: stats.status, message: stats.message)
  end
end

You can add it to the composite reporter just like before.

module Minitest
  def self.plugin_colored_reporter_init(options)
    Minitest.reporter.reporters.clear
    Minitest.reporter << ColoredReporter.new(options[:io], options)
    Minitest.reporter << TestNotifierReporter.new
  end
end

On Mac and Growl, this is what you’ll have:

Growl notification: Minitest meets test_notifier gem

Wrapping up

Minitest is really lightweight and extensible. I never really cared about the assertion syntax (expect vs should vs assert vs must/wont), but RSpec’s feedback made all the difference. It gives you the command for running failing tests and allows you to rapidly view what went wrong. Now that I have the same level of feedback, I’ll probably stick to it. Rails has so many custom test case classes (mailers, jobs, generators, and more) that I can just use it, instead of trying to figure out how to do it with RSpec.