Go to English Blog

Criando reporters personalizados no Minitest

Leia em 4 minutos

O Minitest está tendo um grande momentum ultimamente. Eu estou usando em alguns projetos e, vindo do RSpec, sinto muita falta de seu relatório de execução extrememante detalhado. O Minitest tem alguns reporters disponibilizados no Rubygems, mas nenhum chega perto do RSpec.

Eu decidi, então, que era hora de aprender um pouco mais sobre o Minitest e criei um reporter muito parecido com o do RSpec, que ficou com essa cara:

Reporter do Minitest semelhante ao RSpec

Perceba como ele até exibe o comando que você deve executar quando um teste falha, assim como no RSpec!

Saiba que estender o Minitest é bastante simples, e você verá como fazer isso neste artigo.

Entendendo o sistema de plugins do Minitest

O sistema de plugins do Minitest usa descoberta de arquivos. Ele carrega todos os arquivos presentes no seu $LOAD_PATH que estejam disponíveis em minitest/*_plugin.rb. Então, se tivermos uma gem chamada minitest-utils, podemos criar um arquivo em lib/minitest/utils_plugin.rb, e o Minitest irá carregá-lo. Este arquivo pode implementar dois métodos no módulo Minitest, como você pode ver à seguir:

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

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

Os métodos devem seguir a nomenclatura Minitest.plugin_*_init e Minitest.plugin_*_options. Vamos ver um pouco sobre a responsabilidade de cada um deles.

Se você pretende estender a CLI, você deve implementar o método Minitest.plugin_*_options, que recebe dois parâmetros contendo a instância de OptionParser e um um hash com as opções, incluindo a instância de IO para saída ($stdout). Você poderia adicionar novas opçnoes, como por exemplo para definir se a saída dos testes deve ser colorida ou não.

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

O reporter padrão do Minitest aceita múltiplos reporters: trata-se do da classe Minitest::CompositeReporter. Para criar reporters personalizados ou outras extensões, você deve implementar o método Minitest.plugin_*_init, que pode adicionar seu reporter à lista do composite.

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

Agora que você já sabe como o sistema de plugins funciona, vamos criar um reporter personalizado.

Criando um reporter personalizado

Veja como é a saída do reporter padrão do Minitest:

Minitest default reporter

Como você pode ver, não é muito útil. Por isso, vamos adicionar um pouco de cores.

Para que isso funcione, precisamos limpar os reporters adicionados ao reporter padrão. Você pode fazer isso no método Minitest.plugin_utils_init.

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

Perceba que estamos usando uma classe ColoredReporter; vamos definí-la.

class ColoredReporter < Minitest::StatisticsReporter
end

Agora você precisa definir os metódos usados na geração do relatório. O primeiro método que vamos implementar é o ColoredReporter#record, executado logo após cada execução de teste. Ele permitirá fazer o progresso realtime de nossos testes, indicado com pontos e outros símbolos.

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

Não se esqueça de chamar o super; isso é necessário pois a classe Minitest::StatisticsReporter computa o resultado dos testes. Vamos precisar implementar o método color, que é bastante simples e utiliza escapes como \e[31mTexto\e[0m.

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

À seguir, vamos implementar o método ColoredReporter#report. Ele é executado após a suíte de testes ser executada, e deve exibir quais testes falharam, assim como estatísticas sobre a execução da suíte. Neste caso, vamos delegar cada trecho de saída para métodos específicos.

class ColoredReporter < Minitest::StatisticsReporter
  # ...

  def report
    super

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

O método ColoredReporter#statistics calculará o tempo de execução, assim como outras informações.

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

Já o método ColoredReporter#aggregated_results irá exibir os testes que falharam ou foram pulados.

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

Finalmente, vamos exibir um resumo da execução, contendo quantidade de asserções, testes, falhas e mais.

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

Pronto! Agora podemos visualizar o resultado da execução dos testes com muito mais facilidade.

Nosso reporter personalizado: muito melhor!

Mais um exemplo

E se a gente quiser exibir notificações Growl (ou outro sistema de notificação qualquer)? Com a gem test_notifier, esta é uma tarefa bem simples.

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

Adicione esse reporter assim como você fez com o ColoredReporter.

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

Ao executar os testes, você receberá uma notificação como esta se estiver usando Mac e Growl:

Notificação com Growl: Minitest usando a gem test_notifier

Finalizando

O Minitest é realmente muito leve e extensível. Eu nunca me importei com a sintaxe de asserção (expect vs should vs assert vs must/wont), mas o feedback da CLI do RSpec era quem fazia toda a diferença, com comandos para executar individualmente os testes que falharam, assim como uma saída colorida que facilita bastante a visualização.

Agora que tenho o mesmo nível de feedback, eu provavelmente vou usar apenas o Minitest. O Rails possui muitas classes para testar seus componentes (mailers, jobs, generators, e outros), então em vez de entender como fazer isso com RSpec, posso simplesmente usá-las.