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

これは[TokyuRuby会議08](http://regional.rubykaigi.org/tokyu08/ "TokyuRuby会議08 - Regional RubyKaigi")にて発表した資料を元にQiita向けに再編集したものです。
元々Advent Calendarと共用にしようと思って、どう考えても5分で話せない資料でLTしたのでした。


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

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

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


# 起動



## rspecのコマンドエンドポイント
```ruby
#!/usr/bin/env ruby

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


## rspec/core/runner.rb (1)
```ruby
def self.invoke
  disable_autorun!
  status = run(ARGV, $stderr, $stdout).to_i
  exit(status) if status != 0
end
```
- 標準入力と標準出力、引数を渡してrunを呼んでるだけ。簡単！

```ruby
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)
```ruby
def initialize(options, configuration=RSpec.configuration, world=RSpec.world)
  @options       = options
  @configuration = configuration
  @world         = world
end
```

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



## rspec/core/runner.rb (3)
```ruby
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)
```ruby
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_groups`は`RSpec.world`によってフィルタされ整列済みになっている



# テストケース定義
`RSpec::Core::Configuration`が`load_spec_files`を実行した時に各specファイルが実行され定義される



## ExampleGroupの定義



## rspec/core/example_group.rb (1)
```ruby
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`を作るメソッドを定義する
- `describe`や`xdescribe`、`context`等、複数の名前で定義するため
- テストケースの実体は`ExampleGroup`のsubclassを定義している



## rspec/core/example_group.rb (2)
サブクラスの作り方

```ruby
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とかで止めると見れる数字付の自動生成されたクラス名がそれ
- 最後に`let`や`subject`の定義場所になるモジュールをincludeさせる



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

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



## 各テストケースの定義



## rspec/core/example_group.rb
```ruby
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
```

- `it`や`specify`の実体となるメソッド
- `describe`と同じく別名で複数登録するためメソッドでラップされている
- `ExampleGroup.examples`に`Example`のインスタンスを登録している
- 実はテストケースが実際に実行されるのは`Example`のコンテキストではない



## テスト実行



## rspec/core/runner.rb (再)
```ruby
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)
```ruby
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)
```ruby
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
```ruby
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](https://github.com/joker1007/rspec-power_assert)から一部コードを抜粋してみる。

```ruby
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
```ruby
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
```ruby
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
```ruby
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
```ruby
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
```ruby
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
```ruby
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?`が失敗した時に実行される失敗処理の続きが以下。

```ruby
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
```ruby
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というクラスがテスト失敗のメッセージを表示している。

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

### rspec/core/example\_group.rb
```ruby
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
```ruby
def fail_with_exception(reporter, exception)
  start(reporter)
  set_exception(exception)
  finish(reporter) # テストが終わった時のメッセージ表示
end
```


### rspec/core/example.rb
```ruby
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
```ruby
def example_failed(example)
  @failed_examples << example
  notify :example_failed, Notifications::ExampleNotification.for(example)
end
```

```ruby
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
```ruby
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

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



情報を蓄積するタイプのサンプルとして[rspec_junit_formatter](https://github.com/sj26/rspec_junit_formatter)を見てみよう。

```ruby
def example_passed(notification)
  @example_notifications << notification
end

def example_pending(notification)
  @example_notifications << notification
end

def example_failed(notification)
  @example_notifications << notification
end
```

```ruby
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`を通じて`Formatter`に`Notification`を送る
- 例えばテストが失敗した時は、`Formatter`がそのイベントを受け取り、`Notification`に格納された情報を元にfailure messageを表示する


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

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