372
374

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

RubyAdvent Calendar 2014

Day 1

これを読むとRSpecの裏側がどうやって動いているのか分かるかもしれないぜ

Last updated at Posted at 2014-12-01

これはTokyuRuby会議08にて発表した資料を元にQiita向けに再編集したものです。
元々Advent Calendarと共用にしようと思って、どう考えても5分で話せない資料でLTしたのでした。

最初に

RubyのテスティングフレームワークとしてはトップクラスにメジャーなRSpecですが、内側の実装が黒魔術感に溢れていて非常に読み辛い。
そしてカスタマイズするにも学習コストが高いという話を聞きます。

最近「RSpec止めますか、人間(Rubyist)止めますか」みたいな風潮が出ていてバリバリのRSpec派の私としては見過ごせない感じになってきたので、いっちょRSpecがどんな感じで動いてるのかを大まかに解説していくことで、世の中に対して再びRSpecを啓蒙していこうと思うわけです。

この話はrspec-core-3.1.7辺りをベースにしています。

起動

rspecのコマンドエンドポイント

#!/usr/bin/env ruby

require 'rspec/core'
RSpec::Core::Runner.invoke
  • invoke呼んでるだけ。楽勝ですね。

rspec/core/runner.rb (1)

def self.invoke
  disable_autorun!
  status = run(ARGV, $stderr, $stdout).to_i
  exit(status) if status != 0
end
  • 標準入力と標準出力、引数を渡してrunを呼んでるだけ。簡単!
def self.run(args, err=$stderr, out=$stdout)
  trap_interrupt
  options = ConfigurationOptions.new(args)

  if options.options[:drb]
    require 'rspec/core/drb'
    begin
      DRbRunner.new(options).run(err, out)
    rescue DRb::DRbConnError
      err.puts "No DRb server is running. Running in local process instead ..."
      new(options).run(err, out)
    end
  else
    new(options).run(err, out)
  end
end
  • ConfigurationOptionsのnewを読みにいくと大分辛そうに見えるけど、実際はコマンドラインオプションをパースするのが主な役目でそんなに重要ではない

rspec/core/runner.rb (2)

def initialize(options, configuration=RSpec.configuration, world=RSpec.world)
  @options       = options
  @configuration = configuration
  @world         = world
end
def run(err, out)
  setup(err, out)
  run_specs(@world.ordered_example_groups)
end
  • RSpec::Core::Worldはグローバルに情報を保持しておくための内部用の箱
  • example_groupsのリストやテストケース、フィルタ情報を持っている
  • テストケースが定義される時は、ここにバンバン登録されていく

rspec/core/runner.rb (3)

def setup(err, out)
  @configuration.error_stream = err
  @configuration.output_stream = out if @configuration.output_stream == $stdout
  @options.configure(@configuration)
  @configuration.load_spec_files
  @world.announce_filters
end
  • RSpec::Core::Configurationに設定を保持する
  • load_spec_filesでスペックを読み込み
  • コマンドラインオプションで渡された読み込みパターンに沿って読み込み対象のspecファイルをloadする
  • この時点で定義されたテストケースがRSpec::Core::Worldに登録されている

rspec/core/runner.rb (4)

def run_specs(example_groups)
  @configuration.reporter.report(@world.example_count(example_groups)) do |reporter|
    begin
      hook_context = SuiteHookContext.new
      @configuration.hooks.run(:before, :suite, hook_context)
      example_groups.map { |g| g.run(reporter) }.all? ? 0 : @configuration.failure_exit_code
    ensure
      @configuration.hooks.run(:after, :suite, hook_context)
    end
  end
end
  • suite全体のhookを起動して、example_groups全てをrunしていく
  • hookの呼び方については省略するが、基本はブロックに渡された処理をContextオブジェクトがinstance_execすることによって処理される
  • example_groupsRSpec.worldによってフィルタされ整列済みになっている

テストケース定義

RSpec::Core::Configurationload_spec_filesを実行した時に各specファイルが実行され定義される

ExampleGroupの定義

rspec/core/example_group.rb (1)

def self.define_example_group_method(name, metadata={})
  define_singleton_method(name) do |*args, &example_group_block|
    # ...
      description = args.shift
      combined_metadata = metadata.dup
      combined_metadata.merge!(args.pop) if args.last.is_a? Hash
      args << combined_metadata

      subclass(self, description, args, &example_group_block).tap do |child|
        children << child
      end
    # ...

  RSpec::Core::DSL.expose_example_group_alias(name)
end
  • クラスメソッドとしてExampleGroupを作るメソッドを定義する
  • describexdescribecontext等、複数の名前で定義するため
  • テストケースの実体はExampleGroupのsubclassを定義している

rspec/core/example_group.rb (2)

サブクラスの作り方

def self.subclass(parent, description, args, &example_group_block)
  subclass = Class.new(parent)
  subclass.set_it_up(description, *args, &example_group_block)
  ExampleGroups.assign_const(subclass)
  subclass.module_exec(&example_group_block) if example_group_block

  # The LetDefinitions module must be included _after_ other modules
  # to ensure that it takes precedence when there are name collisions.
  # Thus, we delay including it until after the example group block
  # has been eval'd.
  MemoizedHelpers.define_helpers_on(subclass)

  subclass
end
  • set_it_upメソッドでdescriptionやメタデータをクラス情報として組み込み、Worldに登録する
  • assign_constで動的に生成したクラス名にサブクラスを割り当てる。pryとかで止めると見れる数字付の自動生成されたクラス名がそれ
  • 最後にletsubjectの定義場所になるモジュールをincludeさせる

describeの内側は単なるクラス定義

Refinmentが使えるし、独自のモジュールをincludeすることもできる。

各テストケースの定義

rspec/core/example_group.rb

def self.define_example_method(name, extra_options={})
  define_singleton_method(name) do |*all_args, &block|
    desc, *args = *all_args

    options = Metadata.build_hash_from(args)
    options.update(:skip => RSpec::Core::Pending::NOT_YET_IMPLEMENTED) unless block
    options.update(extra_options)

    examples << RSpec::Core::Example.new(self, desc, options, block)
    examples.last
  end
end
  • itspecifyの実体となるメソッド
  • describeと同じく別名で複数登録するためメソッドでラップされている
  • ExampleGroup.examplesExampleのインスタンスを登録している
  • 実はテストケースが実際に実行されるのはExampleのコンテキストではない

テスト実行

rspec/core/runner.rb (再)

def run_specs(example_groups)
  @configuration.reporter.report(@world.example_count(example_groups)) do |reporter|
    begin
      hook_context = SuiteHookContext.new
      @configuration.hooks.run(:before, :suite, hook_context)
      example_groups.map { |g| g.run(reporter) }.all? ? 0 : @configuration.failure_exit_code
    ensure
      @configuration.hooks.run(:after, :suite, hook_context)
    end
  end
end

example_groups.map { |g| g.run(reporter) }に注目。

rspec/core/example_group.rb (1)

def self.run(reporter)
  # ...
  reporter.example_group_started(self)

  begin
    instance = new('before(:context) hook')
    run_before_context_hooks(instance) # `ExampleGroup`をインスタンス化してbefore(:context)フックを実行
    result_for_this_group = run_examples(reporter) # 自身に登録されている各テストケースを実行していく
    results_for_descendants = ordering_strategy.order(children).map { |child| child.run(reporter) }.all? # 子`ExampleGroup`のテストケース実行
    result_for_this_group && results_for_descendants
    # ...
  ensure
    instance = new('after(:context) hook')
    run_after_context_hooks(instance) # 再度別のインスタンスを作りafter(:context)フックを実行
    before_context_ivars.clear
    reporter.example_group_finished(self)
  end
end

rspec/core/example_group.rb (2)

def self.run_examples(reporter)
  ordering_strategy.order(filtered_examples).map do |example|
    next if RSpec.world.wants_to_quit
    instance = new(example.inspect_output) # テストケース実行のためのインスタンスを作る
    set_ivars(instance, before_context_ivars) # before(:context)で定義したインスタンス変数を動的に定義する
    succeeded = example.run(instance, reporter) # ExampleGroupのインスタンスを渡している
    RSpec.world.wants_to_quit = true if fail_fast? && !succeeded
    succeeded
  end.all?
end
  • 実はbefore(:context)/after(:context)と各テストケースを評価しているコンテキストは別だったりする
  • 何故インスタンス変数が引き回せるのかというと、ExampleGroupのクラスレベルでインスタンス変数を保存しており、テストケース実行時にインスタンスが作成される度にインスタンス変数を勝手に再起動している。中々無茶である。
  • example.run(instance, reporter)ExampleGroupのインスタンスが渡されているのがポイント

rspec/core/example.rb

def run(example_group_instance, reporter)
  # ...
        begin
          run_before_example
          @example_group_instance.instance_exec(self, &@example_block)

          # ...
        rescue Exception => e
          set_exception(e)
        ensure
          run_after_example
        end
      end
    end
  # ...
end
  • @example_group_instance.instance_exec(self, &@example_block)に注目。
  • 評価コンテキストはExampleGroupのインスタンスである
  • テストが実行されるとインスタンスはクリアされる
  • そのため、後から実行時の情報は取得できない

各テストケースはExampleGroupのメソッド呼び出しみたいなもの

つまりdescribeのブロック内でインスタンスメソッド等を定義すると、各テストケースで利用可能になるし、インスタンス変数、クラス変数がイメージ通りに動作する。

テストケースのグループ定義と各テストケースがクラスとインスタンスの関係になっているのは、結構分かりやすいと思う。

Exampleというクラスもあって、名前的に分かりにくいのだが、これはテストケースのメタデータを持っている箱に過ぎない。

動的にテストケースを定義するにはどうするか

私の作った、rspec-power_assertから一部コードを抜粋してみる。

def it_is_asserted_by(description = nil, &blk)
  file, lineno = blk.source_location
  cmd = description ? "it(description)" : "specify"
  eval %{#{cmd} do evaluate_example("#{__callee__}", &blk) end}, binding, file, lineno
end
  • evalを使ってitを呼ぶのが楽
  • この時、file名と行数を指定しておくこと
  • Exampleはインスタンス化された時のブロックの位置を保存しており、実行時の行指定フィルターに利用するため
  • evalで行数をごまかさないと、行指定フィルターが上手く動作しなくなるので注意

マッチャ

rspec-expectationsという分離されたgemによって定義されている。

expect

rspec/expectations/syntax.rb

def enable_expect(syntax_host=::RSpec::Matchers)
  return if expect_enabled?(syntax_host)

  syntax_host.module_exec do
    def expect(value=::RSpec::Expectations::ExpectationTarget::UndefinedValue, &block)
      ::RSpec::Expectations::ExpectationTarget.for(value, block)
    end
  end
end

rspec/expectations/expectation_target.rb

def self.for(value, block)
  if UndefinedValue.equal?(value)
    unless block
      raise ArgumentError, "You must pass either an argument or a block to `expect`."
    end
    BlockExpectationTarget.new(block)
  elsif block
    raise ArgumentError, "You cannot pass both an argument and a block to `expect`."
  else
    new(value)
  end
end
  • まず、ExpectationTargetというラップ用のクラスでテスト対象を包む。

expect(foo).to

def to(matcher=nil, message=nil, &block)
  prevent_operator_matchers(:to) unless matcher
  RSpec::Expectations::PositiveExpectationHandler.handle_matcher(@target, matcher, message, &block)
end
  • toメソッドは引数にマッチャオブジェクトを受け取ってハンドラーを呼ぶ。

RSpec::Expectations::PositiveExpectationHandler

def self.handle_matcher(actual, initial_matcher, message=nil, &block)
  matcher = ExpectationHelper.setup(self, initial_matcher, message)

  return ::RSpec::Matchers::BuiltIn::PositiveOperatorMatcher.new(actual) unless initial_matcher
  matcher.matches?(actual, &block) || ExpectationHelper.handle_failure(matcher, message, :failure_message)
end
  • ポイントは一番下の行
  • マッチャインスタンスのmatches?falseになる時、失敗メッセージの構築を行ってそれを返す
  • つまりマッチャとは何かというと、基本的にはmatches?メソッドが生えてて、何か比較してtrueかfalseを返すもの。

マッチャの例

rspec/matchars.rb

def eq(expected)
  BuiltIn::Eq.new(expected)
end
alias_matcher :an_object_eq_to, :eq
alias_matcher :eq_to,           :eq

rspec/matches/built_in/eq.rb

class Eq < BaseMatcher
  def failure_message
    "\nexpected: #{format_object(expected)}\n     got: #{format_object(actual)}\n\n(compared using ==)\n"
  end
  # ...

private

  def match(expected, actual)
    actual == expected
  end

  # ...
end
  • マッチャはマッチ処理だけでなく失敗した時にどういうメッセージが必要かという情報も保持している。
  • 自分でマッチャを作りたい時は、ビルトインの組込みマッチャを見ると、そんなに難しくない。
  • 呼び出し方のルールは決まっているので、基底クラスを継承して必要なメソッドだけ再定義すればオーケー
  • マッチャのDSLは単にマッチャオブジェクトを作るためのコンストラクタに過ぎない

マッチャ処理が失敗した時

rspec/expectations/handler.rb

先程、マッチャオブジェクトのmatches?が失敗した時に実行される失敗処理の続きが以下。

module ExepectationHelper
  # ...
  def self.handle_failure(matcher, message, failure_message_method)
    message = message.call if message.respond_to?(:call) # カスタムメッセージが渡されていればそちらを優先
    message ||= matcher.__send__(failure_message_method) # マッチャから失敗時にメッセージを取得

    if matcher.respond_to?(:diffable?) && matcher.diffable?
      ::RSpec::Expectations.fail_with message, matcher.expected, matcher.actual
    else
      ::RSpec::Expectations.fail_with message
    end
  end
end

rspec/expectations/fail_with.rb

module RSpec
  module Expectations
    class << self
      # ...
      def fail_with(message, expected=nil, actual=nil)
        unless message
          raise ArgumentError, "Failure message is nil. Does your matcher define the " \
                               "appropriate failure_message[_when_negated] method to return a string?"
        end

        diff = differ.diff(actual, expected)
        message = "#{message}\nDiff:#{diff}" unless diff.empty?

        # マッチャが失敗すると最終的にこの例外が呼ばれる
        raise RSpec::Expectations::ExpectationNotMetError, message
      end
    end
  end
end
  • マッチャによる比較処理が失敗すると、失敗メッセージをマッチャから取得し、例外を起こすだけ
  • 例外によって処理を中断して、rspec-coreに処理を戻す

fail_messageの表示

例外が起きた後、エラーメッセージはどうやって表示しているのか。

Repoter, Notification, Formatterというクラスがテスト失敗のメッセージを表示している。

先程、テストケースの実行で解説したExampleGroupself.runメソッドを再び見てみよう。

rspec/core/example_group.rb

def self.run(reporter)
  # ...
  begin
    # ...
  rescue Exception => ex
    RSpec.world.wants_to_quit = true if fail_fast?
    for_filtered_examples(reporter) { |example| example.fail_with_exception(reporter, ex) }

  # ...
end
  • reporterは起動時から引数によってずっと引き回されている。
  • テスト実行の各工程で通知を受け取りたいFormatterに適宜イベントを送信していく。
  • mediatorっぽい役割と考えられる。

rspec/core/example.rb

def fail_with_exception(reporter, exception)
  start(reporter)
  set_exception(exception)
  finish(reporter) # テストが終わった時のメッセージ表示
end

rspec/core/example.rb

def finish(reporter)
  pending_message = execution_result.pending_message

  if @exception
    record_finished :failed
    execution_result.exception = @exception
    reporter.example_failed self # reporterのexample_failedを呼んで失敗したことを通知する
    false
  elsif pending_message
    record_finished :pending
    execution_result.pending_message = pending_message
    reporter.example_pending self
    true
  else
    record_finished :passed
    reporter.example_passed self
    true
  end
end
  • テストの各工程でreporterのメソッドを呼んでいるのが分かる。

Reporter

rspec/core/repoter.rb

def example_failed(example)
  @failed_examples << example
  notify :example_failed, Notifications::ExampleNotification.for(example)
end
def notify(event, notification)
  registered_listeners(event).each do |formatter|
    formatter.__send__(event, notification) # イベント名を使ってFormatterのメソッドを呼び出す
  end
end
  • Notificationはイベントに対応した情報やメッセージを格納している箱
  • formatterはsendを受け取るためにイベント名に対応したメソッドを実装しておく必要がある
  • 各テストケースの情報はnotificationを経由して取得する

Formatter

rspec/core/formatters/documentation_formatter.rb

module RSpec
  module Core
    module Formatters
      # @private
      class DocumentationFormatter < BaseTextFormatter
        Formatters.register self, :example_group_started, :example_group_finished,
                            :example_passed, :example_pending, :example_failed

def example_passed(passed) # passedはNotificationのインスタンス
  output.puts passed_output(passed.example)
end
def passed_output(example)
  ConsoleCodes.wrap("#{current_indentation}#{example.description.strip}", :success)
end
  • Formatters.registerを呼び出して、どのイベントを受け取るかを宣言する
  • 各イベントに対応したメソッドはNotificationのインスタンスから情報を得て、メッセージを出力したり情報を保存したりする

情報を蓄積するタイプのサンプルとしてrspec_junit_formatterを見てみよう。

def example_passed(notification)
  @example_notifications << notification
end

def example_pending(notification)
  @example_notifications << notification
end

def example_failed(notification)
  @example_notifications << notification
end
def dump_summary(summary)
  xml.instruct!
  testsuite_options = {
    :name => 'rspec',
    :tests => summary.example_count,
    :failures => summary.failure_count,
    :errors => 0,
    :time => '%.6f' % summary.duration,
    :timestamp => @start.iso8601
  }
  xml.testsuite testsuite_options do
    xml.properties
    @example_notifications.each do |notification|
      send :"dump_summary_example_#{notification.example.execution_result[:status]}", notification
    end
  end
end
  • 各イベントでは何も表示せずにNotificationを集めているだけ。
  • 最後に呼ばれるdump_summaryというイベントを受け取って、今まで蓄積した情報を元にXMLを出力している。

カスタムFormatterを作るのは簡単

  • RSpec::Core::Formatters::BaseFormatterを継承したクラスを作る
  • RSpec::Formatters.registerを呼んで、対応するイベントを宣言する
  • 各イベントに対応したメソッドを定義する

ただし、RSpec2系と両対応しようとすると、若干面倒。
全然互換性が無いのでクラスを二つ用意してRSpecのバージョンで分岐させる必要がある。

まとめ

起動

  • コマンドライン引数を元にテストケースを読み込んでWorldに登録

テストケース定義

  • describeの定義はExampleGroupのサブクラスを定義することと同じ
  • 各テストケースはExampleという箱に情報を保持している
  • 各テストケースのが実行される時はExampleGroupのインスタンスにevalされる

マッチャ

  • アサーションは、ExpectationTargetに対してマッチャインスタンスのmatches?を呼ぶ
  • マッチしない時はRSpec::Expectations::ExpectationNotMetError例外が発生する

メッセージの表示

  • テスト実行の各工程でRepoterを通じてFormatterNotificationを送る
  • 例えばテストが失敗した時は、Formatterがそのイベントを受け取り、Notificationに格納された情報を元にfailure messageを表示する

というわけで、RSpecの裏側の基本的な処理の流れについて、ざっくりと書いてみました。
誰がこんなの最後まで読むんだ、という感じであんまりRSpecの覇権を再び!みたいな感じにはならないような気もする…。

弄る可能性が高いのは、テストケースを動的に定義する場合とカスタムFormatterなので、その辺りに集中して読んでみるとRSpecを拡張したり、gemで引っかかっても読む手掛かりができるかもしれません。

372
374
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
372
374

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?