Rubyノート - 定義するだけで実行される Test::Unit::TestCase の仕組み

% ruby tc_hoge.rb

とやるとテストが走る。tc_hoge.rb にはクラスの定義しかないのに、なぜテストが実行されるのか? 長らく疑問だったので、Test::Unit のコードを追ってその仕組みを調べてみることにした。

まずは require 'test/unit' で最初に読み込まれるコード

# /usr/lib/ruby/1.8/test/unit.rb

at_exit do
  unless $! || Test::Unit.run?
    exit Test::Unit::AutoRunner.run
  end
end

ありゃ、末尾にいきなり答えが。

一連の TestCase の定義が終わり、まさにプログラムが終了しますという、そのタイミングにフックしてテストを実行している。おそらく、AutoRunner というクラスが定義済みの TestCase を順番に実行していくようになっているのだろう。

定義済みの TestCase はその参照がどこかに保存されていなければならない。そのために Class#inherited が使われているはずだ。

と思って検索をかけるが、ない。
AutoRunner は定義済みの TestCase の集合をどこから得ているのか。

AutoRunner が何をやっているか調べる。わかっていることは、AutoRunner.run で定義済みの TestCase が順次走るということだ。

# /usr/lib/ruby/1.8/test/unit/autorunner.rb

module Test
  module Unit
    class AutoRunner
      def self.run(force_standalone=false, default_dir=nil, argv=ARGV, &block)
        r = new(force_standalone || standalone?, &block)
        r.base = default_dir
        r.process_args(argv)
        r.run
      end

      def self.standalone?
        return false unless("-e" == $0)
        ObjectSpace.each_object(Class) do |klass|
          return false if(klass < TestCase)
        end
        true
      end

まず、AutoRunner を new するにあたり、standalone かどうかの判定がある。で、AutoRunner.standalone? の定義を見ると、standalone かどうかは ObjectSpace に定義済みの TestCase があるかどうか調べることで判定している。今回調べているケースでは TestCase は定義済みだから、standalone ではないということだ。

見えてきた。定義済み TestCase もここから取得しているに違いない。

Autorunner#initialize へ

      def initialize(standalone)
        Unit.run = true
        @standalone = standalone
        @runner = RUNNERS[:console]
        @collector = COLLECTORS[(standalone ? :dir : :objectspace)]
        @filters = []
        @to_run = []
        @output_level = UI::NORMAL
        @workdir = nil
        yield(self) if(block_given?)
      end

standalone でない場合、@collectorCOLLECTOR[:objectspace] になる。名前からして、これが TestCase を collect する(集める)ものに違いない。

      COLLECTORS = {
        :objectspace => proc do |r|
          require 'test/unit/collector/objectspace'
          c = Collector::ObjectSpace.new
          c.filter = r.filters
          c.collect($0.sub(/\.rb\Z/, ''))
        end,
        :dir => proc do |r|
          require 'test/unit/collector/dir'
          c = Collector::Dir.new
          c.filter = r.filters
          c.pattern.concat(r.pattern) if(r.pattern)
          c.exclude.concat(r.exclude) if(r.exclude)
          c.base = r.base
          $:.push(r.base) if r.base
          c.collect(*(r.to_run.empty? ? ['.'] : r.to_run))
        end,
      }

その実体は Procオブジェクトで、 Collector::ObjectSpace というクラスを使って何やら collect し(集め)ている。きっとこれが定義済み TestCase の集合を取得する処理に違いない。

Collector::ObjectSpace を見てみる。

# /usr/lib/ruby/1.8/test/unit/collector/objectspace.rb

module Test
  module Unit
    module Collector
      class ObjectSpace
        include Collector

        NAME = 'collected from the ObjectSpace'

        def initialize(source=::ObjectSpace)
          super()
          @source = source
        end

        def collect(name=NAME)
          suite = TestSuite.new(name)
          sub_suites = []
          @source.each_object(Class) do |klass|
            if(Test::Unit::TestCase > klass)
              add_suite(sub_suites, klass.suite)
            end
          end
          sort(sub_suites).each{|s| suite << s}
          suite
        end
      end
    end
  end
end

予想通り。ObjectSpace から TestCase をかき集め、それを TestSuite に突っ込んでいる。

で、最後、Autorunner#run

# /usr/lib/ruby/1.8/test/unit/autorunner.rb

      def run
        @suite = @collector[self]
        result = @runner[self] or return false
        Dir.chdir(@workdir) if @workdir
        result.run(@suite, @output_level).passed?
      end

@collector が返す TestSuite を実行、とこういう流れになっていた。

まとめ
  • at_exit でプログラム終了時にフック
  • ObjectSpace を検索して TestCase を収集

以上、色々と勉強になった。