Test::Unit に Test::Tap を被せてみました

Ruby の添付ライブラリ test/unit は、Java のテスト・フレームワークを範にしているようで、煩雑で軽やかさがないのが難点です。なぜ、Perl のテスト・フレームワークに倣わなかったのか、Ruby の不思議の一つだと思っています。id:dankogai さんが不満を述べるのも、わかる気がします。

404 Blog Not Found:Ruby beyond Rails - 書評 - まるごとRuby!
RubyPerlに比べて、余計なところでTMTOWTDIを発揮しているように思えてならない。それを一番強く感じるのがテストのフレームワークで、なんであんなに種類があるのかわからない。TAPでほぼ統一されているPerlの連帯感からすると、テスト一個のためにクラスを書かせるなんて、間違った傲慢(false form of hubris)にしか感じられないのだが。

もっとも、賢いテキスト・エディタを使っていれば、クラス記述を手打ちすることはないですし、assert 文もテキスト補間でビシバシ打っていけるので、テストの記述作業自体は煩雑ではありません。むしろ、テストを読むときに煩雑なのが気にくわないところでしょう。
そして、id:dankogai さんが既に短い tap.rb を作ってくれていますが、あまりにもこれは単純化しすぎで、もう少し賢くしたくなりました。
404 Blog Not Found:「同じコード」の同じって何さ - TAPのススメの tap.rb 参照
作るのはいいのですがゼロから作るのはバカバカしく、あるものは使おうと test/unit フレームワークに被せるデコレータにしてみました。こんな感じで tests 関数のブロックに、Perl の Test::More を使ったテストに相当するものを記述することができます。ok にブロックを与えて、ブロックの実行結果が真かどうかでテストすることもできるようにしています。注意点が一つあって like と unlike の文字列と正規表現の順番を Perl の Test::More に合わせたため、test/unit の assert_match とは逆になっている点には使うときに注意しなければいけません。TODO と SKIP はありません。

#!/usr/bin/ruby

require 'test/tap'

# tests(:no_plan) do
tests(11) do
  ok true, 'test1'
  ok false, 'test2'
  ok 'test3' do true end
  ok true, 'test4'
  isnt 1, 2, 'test5'
  is 1, 1, 'test6'
  h = {"a" => [3,5,7,13], "b"=>"hoge"}
  is_deeply h, {"a" => [3,5,8,13], "b"=>"hoge"}, 'test7'
  like '123', /\d+/, 'test8'
  raise_ok RuntimeError do raise 'Boom!!!' end
  lives_ok 'divide' do 1 / 1 end
  throws_ok :got do throw :got end
end

出力は、もちろん Test Anything Protocol (TAP) にしたがいます。

$ ruby testsample.t
# testing testsample.t
1..11
ok 1 - test1
not ok 2 - test2
# test2.
# <false> is not true.
ok 3 - test3
ok 4 - test4
ok 5 - test5
ok 6 - test6
not ok 7 - test7
# test7.
# <{"a"=>[3, 5, 8, 13], "b"=>"hoge"}> expected but was
# <{"a"=>[3, 5, 7, 13], "b"=>"hoge"}>.
ok 8 - test8
ok 9 - raise RuntimeError
ok 10 - divide
ok 11 - throw got
# 11 assertions, 2 failures, 0 errors
$

test/tap.rb は以下のとおりです。test/unit フレームワークは、TestRunner クラスにノーティファイを送って経過表示をするようになってますので、コンソール用の TestRunner を継承した経過出力クラスを作って、TAP にしたがった出力をおこないます。そして、テスト本体を記述するための、Test::Unit::TestCase を継承したクラスを定義して、tests クラス・メソッドに与えたブロックをそのクラスのメソッドに定義しています。また、test/unit フレームワークがクラスのメソッド定義単位でのテストをおこなうようになっているため、これを assert 呼び出しごとにテストをおこなうように assert メソッドをデコレートした ok メソッドを作りました。

require "test/unit"
require "test/unit/ui/console/testrunner"

=begin
== License

Test::Tap and Test::Unit::UI::Tap::TestRunner are copyright (c) 2008 MIZUTANI, Tociyuki.
They are free software, and are distributed under the Ruby license.
See the COPYING file in the standard Ruby distribution for details.

=end

module Test
  module Unit
    class TestResult
      attr_accessor :message
    end
    
    module UI
      module Tap
        class TestRunner < Test::Unit::UI::Console::TestRunner
          def setup_mediator
            @mediator = create_mediator(@suite)
            suite_name = @suite.to_s
            if ( @suite.kind_of?(Module) )
              suite_name = @suite.name
            end
            output("\# testing #{suite_name}")
          end
          
          def attach_to_mediator
            @mediator.add_listener(TestRunnerMediator::STARTED, &method(:started))
            @mediator.add_listener(TestResult::CHANGED, &method(:result_changed))
            @mediator.add_listener(TestResult::FAULT, &method(:add_fault))
            @mediator.add_listener(TestRunnerMediator::FINISHED, &method(:finished))
            @mediator.add_listener(TestCase::STARTED, &method(:test_started))
            @mediator.add_listener(TestCase::FINISHED, &method(:test_finished))
          end
          
          def test_started(name)
          end
          
          def test_finished(name)
          end
          
          def started(result)
            @result = result
            @lastmessage = nil
            @fault = nil
            @no = 0
            @ntest = Test::Tap.ntest
            output "1..#{@ntest}" unless @ntest == :no_plan
          end

          def result_changed(result)
            if @fault
              @lastmessage ||= ''
              output("not ok #{@no}" + (@lastmessage != '' ? " - " + @lastmessage : ''))
              output(("# " + @fault.message).gsub(/\n/, "\n# "))
              @lastmessage = nil
              @fault = nil
            elsif @lastmessage
              output("ok #{@no}" + (@lastmessage != '' ? " - " + @lastmessage : ''))
              @lastmessage = nil
            end
            if @result.message
              @lastmessage = @result.message.to_s
              @result.message = nil
              @no += 1
            end
          end

          def add_fault(fault)
            @fault = fault
          end
          
          def finished(elapsed_time)
            if @ntest == :no_plan
              output "1..#{@result.assertion_count}"
            end
            output("\# #{@result.assertion_count} assertions, #{@result.failure_count} failures, #{@result.error_count} errors")
          end
          
        end
      end
    end

    class AutoRunner
      RUNNERS[:original] = RUNNERS[:console]
      RUNNERS[:console] = proc do |r|
        Test::Unit::UI::Tap::TestRunner
      end
    end
  end

  class Tap < Test::Unit::TestCase
    def ok(boolean=nil, message=nil, &p)
      if message.nil? && block_given?
        message = boolean || ''
        call_assertion(message) { assert_block(message, &p) }
      else
        call_assertion(message) { assert(boolean, message) }
      end
    end
    
    def is(got, expected, message='')
      call_assertion(message) { assert_equal(expected, got, message) }
    end
    
    alias is_deeply is
    
    def isnt(got, expected, message='')
      call_assertion(message) { assert_not_equal(expected, got, message) }
    end
    
    def is_nil(got, message='')
      call_assertion(message) { assert_equal(nil, got, message) }
    end
    
    def isnt_nil(got, message='')
      call_assertion(message) { assert_not_equal(nil, got, message) }
    end
    
    def like(got, regexp, message='')
      call_assertion(message) { assert_match(regexp, got, message) }
    end
    
    def unlike(got, regexp, message='')
      call_assertion(message) { assert_no_match(regexp, got, message) }
    end
    
    def can_ok(object, method, message='')
      call_assertion(message) { assert_respond_to(object, method, message) }
    end
    
    def isa_ok(object, klass, message='')
      call_assertion(message) { assert_kind_of(klass, object, message) }
    end
    
    def raise_ok(*a, &p)
      call_assertion("raise #{a.map{|x| x.to_s }.join(', ')}") { assert_raise(*a, &p) }
    end
    
    def lives_ok(message='', &p)
      call_assertion(message) { assert_nothing_raised(message, &p) }
    end
    
    def throws_ok(sym, &p)
      call_assertion("throw #{sym}") { assert_throws(sym, "", &p) }
    end
    
    def call_assertion(message='')
      @_result.message = message || ''
      begin
        yield
      rescue Test::Unit::AssertionFailedError => e
        add_failure(e.message, e.backtrace)
      rescue Exception
        add_error($!)
      end
    end
    
    def self.ntest() @@ntest end
  
    def self.tests(n, &p)
      Integer === n || n == :no_plan or raise "tests(Integer || :no_plan) do .. end"
      @@ntest = n
      define_method(:test_them, &p)
    end
  end
end

def tests(n, &p) Test::Tap.tests(n, &p) end