Rspecの世界観を読む

# この記事は
テストライブラリは毎回自分で書いていた私です。

テストのレビュワーがいない仕事で生きていたのでそれで良かったのですが、
標準的な書き方でもかけたほうが良いだろうということでRSpecを使ってみました。

Rspec、一言いうと「やることは少ないけど理解は難しい」という感じがしましたね…。
まず公式ドキュメント…[公式ってこれだけですよね？](https://rspec.info/documentation/)

ひとまず[使えるRSpec入門](https://qiita.com/jnchito/items/42193d066bd61c740612)を参考にさせていただきまして、そもそもRSpecってどう動いているのかをコードで追ってみました。
コードを読むことで設計とともにRSpec的世界観が見えた気がしたので、それを書き留めるのが今回の趣旨です。

分量が多いので2回か3回に分けて文書化しようと思います。今回はRSpecの世界観の核を構成している

- describe
- it
- expect / it_expected
  
の実装と動作メカニズムまでが範囲です。

# Hello RSpec.world!
根幹にあたるRspecクラスには以下の2つのattributeが定義されています。

- :configuration
- :world

[rspec-core/core.rb](https://github.com/rspec/rspec-core/blob/fe3084758857f0714f05ada44a18f1dfe9bf7a7e/lib/rspec/core.rb)

```ruby:rspec/core.rb
module RSpec
  autoload :SharedContext, 'rspec/core/shared_context'

  extend RSpec::Core::Warnings

  class << self
    # Setters for shared global objects
    # @api private
    attr_writer :configuration, :world
  end
```

結論から言えば、RSpecはこのworld上に、大きな方から以下のような概念をネストしてテストを実行してます。

- example_group
- example
- expectation

[rspec-core/runner.rb](https://github.com/rspec/rspec-core/blob/fe3084758857f0714f05ada44a18f1dfe9bf7a7e/lib/rspec/core/runner.rb)にあるRunnerクラスのインスタンスは以下の順で動作します。

- RSpecのコードを処理しながらworldを組み立てる
- world内のexpectationが満たされているか実際にチェックする
- 結果のレポートを書き出す
  
let, subjectやbefore, afterなどのフックも一度worldの中にプログラムされることで遅延実行を実現しているようです。

## エントリポイントからテスト完了まで
gemのbinディレクトリにあるrspecをコマンドラインから実行するところからコードを追いかけてみます。
ruby3.0x環境で./vendorにbundle installしました。
同じ手順ならエントリポイントのパスは./vendor/ruby/3.0.0/bin/rspecになります。

```ruby:rspec
#!/usr/bin/env ruby

…

if Gem.respond_to?(:activate_bin_path)
load Gem.activate_bin_path('rspec-core', 'rspec', version)
else
gem "rspec-core", version
load Gem.bin_path("rspec-core", "rspec", version)
end
```

rspec-coreのrspecを読み込むことでテストが開始するようです。

※bin_pathとactivate_bin_pathの違いは対象のGemをactivateするかどうからしいです。それって実際何？
コードと[ここ](https://www.rubydoc.info/gems/rubygems-update/Gem#activate_bin_path-class_method)を読む感じだと、activate_bin_pathは新しめのバージョンで追加されたbinstab用のメソッドらしいですが。

[rspec-core/exe/rspec](https://github.com/rspec/rspec-core/blob/main/exe/rspec)
先ほどのコマンド実行用エントリポイントからloadされるファイルです。

```ruby:rspec-core/exe/rspec
#!/usr/bin/env ruby

require 'rspec/core'
RSpec::Core::Runner.invoke
```


[rspec-core/core.rb](https://github.com/rspec/rspec-core/blob/fe3084758857f0714f05ada44a18f1dfe9bf7a7e/lib/rspec/core.rb) -> [rspec-support/support.rb](https://github.com/rspec/rspec-support/blob/main/lib/rspec/support.rb) -> rspec-core各種ライブラリの順に読み込んで
rspec-core/runnerにあるRunnerクラスのinvokeが実行されています。ライブラリの読み込みは以下のように進みます。


```ruby:rspec-core/core.rb
require "rspec/support"
```

```ruby:rspec-support/support.rb
def self.define_optimized_require_for_rspec(lib, &require_relative)
  name = "require_rspec_#{lib}"

  if RUBY_PLATFORM == 'java' && !Kernel.respond_to?(:require)
    # JRuby 9.1.17.0 has developed a regression for require
    (class << self; self; end).__send__(:define_method, name) do |f|
      Kernel.send(:require, "rspec/#{lib}/#{f}")
    end
  elsif Kernel.respond_to?(:require_relative)
    (class << self; self; end).__send__(:define_method, name) do |f|
      require_relative.call("#{lib}/#{f}")
    end
  else
    (class << self; self; end).__send__(:define_method, name) do |f|
      require "rspec/#{lib}/#{f}"
    end
  end
end
```

```ruby:rspec-core/core.rb
RSpec::Support.define_optimized_require_for_rspec(:core) { |f| require_relative f }
```

環境のruby自体の実装ごとに読み込み方を変えているのでしょうか？
Runnerクラスが呼び出せるようになって、先ほどのRunner.invokeが動きます。[rspec-core/runner.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/runner.rb)を見ると

```ruby:rspec-core/runner.rb
def self.invoke
  disable_autorun!
  status = run(ARGV, $stderr, $stdout).to_i
  exit(status) if status != 0
end

…

def self.run(args, err=$stderr, out=$stdout)
  trap_interrupt
  options = ConfigurationOptions.new(args)

  if options.options[:runner]
    options.options[:runner].call(options, err, out)
  else
    new(options).run(err, out)
  end
end
```

ここでコマンドライン引数をoptionsに格納して、runnerインスタンスを作成。
作成されるインスタンスは以下のように初期化され、ここでworldが登場します。

```ruby:rspec-core/runner.rb
def initialize(options, configuration=RSpec.configuration, world=RSpec.world)
  @options       = options
  @configuration = configuration
  @world         = world
end
```

Runnerのインスタンスメソッドのrunは

```ruby:rspec-core/runner.rb
def run(err, out)
  setup(err, out)
  return @configuration.reporter.exit_early(exit_code) if RSpec.world.wants_to_quit

  run_specs(@world.ordered_example_groups).tap do
    persist_example_statuses
  end
end
```

setup　→　run_specsの2つの処理を行っています。

```ruby:rspec-core/runner.rb
def setup(err, out)
  configure(err, out)
  return if RSpec.world.wants_to_quit

  @configuration.load_spec_files
ensure
  @world.announce_filters
end
```

configurationsは先ほどRSpec.configurationがデフォルト引数に入っていましたが、これは
[rspec-core/configuration.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/configuration.rb)にソースがあって

```ruby:rspec-core/configuration.rb
def load_spec_files

…

  world.registered_example_group_files.each do |f|
    loaded_spec_files << f # the registered files are already expended absolute paths
  end

  files_to_run.uniq.each do |f|
    file = File.expand_path(f)
    load_file_handling_errors(:load, file)
    loaded_spec_files << file
  end

  @spec_files_loaded = true
end

…

def load_file_handling_errors(method, file)
  __send__(method, file)
rescue LoadError => ex

…

end
```

ソース中のコメントも手掛かりに解釈すると、取得したspecファイルのパスに対してテストコードを先頭から読んでいます。
※以下の2つのコードが実行する内容は同じ。

```ruby
__send__(:load, file)
```
```ruby
load(file)
```

読み込んだ後に実行されているrun_specは[rspec-core/runner.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/runner.rb)の中にありました。

```ruby:rspec-core/runner.rb
def run_specs(example_groups)
  examples_count = @world.example_count(example_groups)
  examples_passed = @configuration.reporter.report(examples_count) do |reporter|
    @configuration.with_suite_hooks do
      if examples_count == 0 && @configuration.fail_if_no_examples
        return @configuration.failure_exit_code
      end

      example_groups.map { |g| g.run(reporter) }.all?
    end
  end

  exit_code(examples_passed)
end
```

引数example_groupsは呼び出し元で@world.ordered_example_groupsとなっており、world全体のexample_groupsです。
これをmapして木構造にネストされたexample_groupを順番に精査していくようです。

ここまで冒頭に書いた以下のようなRunnerの動きを実際に追ってみました。

- RSpecのコードを処理しながらworldを組み立てる
- world内のexpectationが満たされているか実際にチェックする
- 結果のレポートを書き出す

ここからRSpecの文法に従ってコーディングしたテストがどのようにworldを形成して実行されるのかを調べていきます。

# テストコードとworldの構造

ここからはbyebugも併用して、コードが読みにくい部分はbyebugの結果から推測して追跡します。

## 確認用のコード(ベース)

確認のために試験対象のクラスと試験用のコードを準備します。
以下のコードに手を加えるような形で検証していきます。

```ruby:someclass.rb
class SomeClass
  attr_reader :color
  attr_accessor :shape
  
  def initialize
    @color = 'yellow'
    @shape = 'square'
  end

  def do_awesome
    @shape = 'cube'
  end
end
```

```ruby:someclass_spec.rb
require './someclass'
require 'byebug'

RSpec.describe 'SomeClass' {
  let(:kore) { are }
  let(:are) { sore }
  describe 'initialize' {
    let(:sore) { SomeClass.new }
    subject { kore.color }
    it {
      is_expected.to eq 'yellow'
    }
  }
}
```

テストコードのメソッドが何をしているのかの概要は冒頭の参考URLに詳しいので、こちらでは説明を省略します。

## describeが作る小さな世界
Rspecのコードを書くときは十中八九、RSpec.describeからスタートするのではないでしょうか？
この重要なdescribeというメソッド、一般にグループを作るとされていますが一体何をしているんでしょうか？

describeのメソッド定義はソースコードの中にはありません。（これが今回結構読むのに苦労しました…）
最初から定義されているわけではなく、[rspec-core/dsl.rb](https://github.com/rspec/rspec-core/blob/ea8554afd1a2b63677c6593059fa8f2476181deb/lib/rspec/core/dsl.rb)を使って[rspec-core/example_groups.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/example_group.rb)の中で作られています。

```ruby:rspec-core/example_groups.rb
define_example_group_method :example_group

# An alias of `example_group`. Generally used when grouping examples by a
# thing you are describing (e.g. an object, class or method).
# @see example_group
define_example_group_method :describe

# An alias of `example_group`. Generally used when grouping examples
# contextually (e.g. "with xyz", "when xyz" or "if xyz").
# @see example_group
define_example_group_method :context
```

```ruby:rspec-core/example_groups.rb
def self.define_example_group_method(name, metadata={})
  idempotently_define_singleton_method(name) do |*args, &example_group_block|
    thread_data = RSpec::Support.thread_local_data
    top_level   = self == ExampleGroup

    registration_collection =
      if top_level
        if thread_data[:in_example_group]
          raise "Creating an isolated context from within a context is " \
                "not allowed. Change `RSpec.#{name}` to `#{name}` or " \
                "move this to a top-level scope."
        end

        thread_data[:in_example_group] = true
        RSpec.world.example_groups
      else
        children
      end

    begin
      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, registration_collection, &example_group_block)
    ensure
      thread_data.delete(:in_example_group) if top_level
    end
  end

  RSpec::Core::DSL.expose_example_group_alias(name)
end
```

```ruby:rspec-core/dsl.rb
def self.expose_example_group_alias(name)
  return if example_group_aliases.include?(name)

  example_group_aliases << name

  (class << RSpec; self; end).__send__(:define_method, name) do |*args, &example_group_block|
    group = RSpec::Core::ExampleGroup.__send__(name, *args, &example_group_block)
    RSpec.world.record(group)
    group
  end

  expose_example_group_alias_globally(name) if exposed_globally?
end
```

途中の以下の部分でnameに:describeが与えられることでメソッドとして使える状態になる、という実装でした。
可変長引数とブロックを取ってExampleGroupクラスのインスタンスを生成するメソッドとして生成されていますね。

```
__send__(:define_method, name) doブロック
```

ruby力が足りず、どうしてDSLでdefine_moduleしたメソッドがExampleGroupクラスで使えるのかなど解読不能な個所が多いコードでしたが、こんな時は実際にdescribeすると何が起きるのかbyebugを使って直に見てみます。

```ruby:someclass_spec.rb
require './someclass'
require 'byebug'

byebug
```

```byebug:byebug
(byebug) RSpec.describe
RSpec::ExampleGroups::Anonymous
(byebug) RSpec.describe
RSpec::ExampleGroups::Anonymous_2
(byebug) RSpec.describe 'some_class'
RSpec::ExampleGroups::SomeClass
(byebug) s = RSpec.describe 'some_thing'
RSpec::ExampleGroups::SomeThing
(byebug) s.class
Class
(byebug) s.name
"RSpec::ExampleGroups::SomeThing"
(byebug) s.describe
RSpec::ExampleGroups::SomeThing::Anonymous
```
こんな感じでdescribeはExampleGroups名前空間に新しいクラスを生成し、そのクラスもまたdescribeを使って自身の名前空間上に新しいクラスを作ることができるものだということが見えてきます。

もう一つ観察してみます。この小さな世界には何があるのかです。テストコードを実行してworldが出来上がった後でbyebugを仕掛けて小さな世界をダンプしてみます。

```ruby:someclass_spec.rb
require './someclass'
require 'byebug'

RSpec.describe('SomeClass') {
  let(:kore) { are }
  let(:are) { sore }
  describe('initialize') {
    let(:sore) { SomeClass.new }
    subject { kore.color }
    it {
      is_expected.to eq 'yellow'
    }
  }
}
byebug
```

```byebug:byebug
(byebug) RSpec::ExampleGroups::SomeClass::Initialize.methods
```
長いので省略しますが:parent_groupsや:subclassのようなExampleGroupの上下関係を示すためのメソッドや、検証対象を示す:examples、before, afterでのフックイベントを示す:hookなんかが気になります。また

```byebug:byebug
(byebug) RSpec::ExampleGroups::SomeClass::Initialize.instance_methods
```
を確認すると、:are, :kore, :soreといった明らかに元々あったとは思えないメソッドが登録されていて、作られた小さな世界=example_groupの中に、expectationを検証するためのツールが詰め込まれていることが分かりました。

### context, example_group

[rspec-core/example_groups.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/example_group.rb)を見直すと、example_groupsとcontextがdescribeと完全に同じ手順でメソッド定義されている分かりますね。

```ruby:rspec-core/example_groups.rb
define_example_group_method :example_group
… # コメント部分
define_example_group_method :describe
… # コメント部分
define_example_group_method :context
```

## itで検証単位を作る
ところでRSpec::ExampleGroupの下位クラスであるRSpec::ExampleGroups::SomeClass::Initializeは
先ほどの検証中にexamplesメソッドを持っていて以下のような結果を返していました。

```byebug:byebug
(byebug) RSpec::ExampleGroups::SomeClass::Initialize.examples
[#<RSpec::Core::Example "example at ./someclass_spec.rb:10">]
```

10行目となるとitから始まる行ですので、恐らくitが検証対象の処理を:examplesの中に登録しているのでしょう。[rspec-core/example_groups.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/example_group.rb)をもう一度見直してみます

```ruby:rspec-core/example_groups.rb
# Defines an example within a group.
define_example_method :example
# Defines an example within a group.
# This is the primary API to define a code example.
define_example_method :it
# Defines an example within a group.
# Useful for when your docstring does not read well off of `it`.
# @example
#  RSpec.describe MyClass do
#    specify "#do_something is deprecated" do
#      # ...
#    end
#  end
define_example_method :specify
```

このパターンはdescribeの時に見ました。itとexampleとspecifyが全く同じメソッド。

```ruby:rspec-core/example_groups.rb
def self.define_example_method(name, extra_options={})
  idempotently_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)

    RSpec::Core::Example.new(self, desc, options, block)
  end
end
```

脇道に話がそれますが、itの後ろにブロックがなかった場合はテスト未実装という形でpending扱いになるみたいですね。
書き途中ならブロックをコメントアウトすればいいのかな。

```ruby:rspec-core/example_groups.rb
options.update(:skip => RSpec::Core::Pending::NOT_YET_IMPLEMENTED) unless block
```

話を戻して。
[rspec-core/example.rb](https://github.com/rspec/rspec-core/blob/ea8554afd1a2b63677c6593059fa8f2476181deb/lib/rspec/core/example.rb)にあるExampleクラスのインスタンスを生成し、その生成側でexample_groupの:examplesに自身をセットしています。

```ruby:rspec-core/example.rb
def initialize(example_group_class, description, user_metadata, example_block=nil)
  @example_group_class = example_group_class
  @example_block       = example_block

…

  example_group_class.examples << self
…
end
```



exampleに渡されたブロックがいつ実行されるのかは気になります。
想定ではworldを組み立てた後でexample_groupごとに実行されるはずですが、これは[rspec-core/runner.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/runner.rb)から辿って

```ruby:rspec-core/runner.rb
def run(err, out)
…
  run_specs(@world.ordered_example_groups).tap do
    persist_example_statuses
  end
end

…

def run_specs(example_groups)
…
      example_groups.map { |g| g.run(reporter) }.all?
…
end
```

[rspec-core/example_groups.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/example_group.rb)のrunを実行します。

```ruby:example_groups.rb
def self.run(reporter=RSpec::Core::NullReporter)
  return if RSpec.world.wants_to_quit
  reporter.example_group_started(self)

  should_run_context_hooks = descendant_filtered_examples.any?
  begin
    run_before_context_hooks(new('before(:context) hook')) if should_run_context_hooks
    result_for_this_group = run_examples(reporter)
    results_for_descendants = ordering_strategy.order(children).map { |child| child.run(reporter) }.all?
    result_for_this_group && results_for_descendants

…

end

…

def self.children
  @children ||= []
end
```

トップレベルから入る時だけrunnerのrun_specsでループ処理をしていますが、そのあとは[rspec-core/example_groups.rb](https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/example_group.rb)のrunメソッドに移譲されています。直下のexamplesがあれば検証して下位のexample_groupがあれば下位クラスのrunに入ってループするような流れです。

## expectationはworldを構成しない
example_group

- describe
- context
- example_group

example

- it
- specify
- example

はworldを構成するメソッドで、worldないし下位の小さな世界に属するオブジェクト作ることが分かりました。
expectやis_expectedはどうでしょう？

is_expectedはexpectのラッパーだと言われますがこれは[rspec-core/memoized_helpers.rb](https://github.com/rspec/rspec-core/blob/ea8554afd1a2b63677c6593059fa8f2476181deb/lib/rspec/core/memoized_helpers.rb)で確認可能です。

```ruby:rspec-core/memoized_helpers.rb
def is_expected
  expect(subject)
end
```


expectは[rspec-expectations/syntax.rb](https://github.com/rspec/rspec-expectations/blob/43bf64b01f8356979ffbc373b2e81d2ab1389b29/lib/rspec/expectations/syntax.rb)と[rspec-expectations/expectation_target.rb](https://github.com/rspec/rspec-expectations/blob/43bf64b01f8356979ffbc373b2e81d2ab1389b29/lib/rspec/expectations/expectation_target.rb)に実装されていて

```ruby:rspec-expectations/syntax.rb
def expect(value=::RSpec::Expectations::ExpectationTarget::UndefinedValue, &block)
  ::RSpec::Expectations::ExpectationTarget.for(value, block)
end
```
```ruby: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
    ValueExpectationTarget.new(value)
  end
end
```

値とブロックを受け取ることができ、以下のように結果を返すように実装されていました。

- 値のみ与えられた: ValueExpectationTargetを返す
- ブロックのみ与えられた: BlockExpectationTargetを返す
- 値とブロックが与えられたか、何も与えられなかった: ArgumentErrorをraiseする

ところでテストコードの実装を考えると

```ruby:someclass_spec.rb
it {
  is_expected.to eq 'yellow'
}
```

または

```ruby:someclass_spec.rb*
it {
  expect(subject).to eq 'yellow'
}
```

という形で、直後にto(あるいは、not_to)を呼ぶことになっています。
このメソッドは[rspec-expectations/expectation_target.rb](https://github.com/rspec/rspec-expectations/blob/43bf64b01f8356979ffbc373b2e81d2ab1389b29/lib/rspec/expectations/expectation_target.rb)にあり、[rspec-expectations/handler.rb](https://github.com/rspec/rspec-expectations/blob/43bf64b01f8356979ffbc373b2e81d2ab1389b29/lib/rspec/expectations/handler.rb)に渡すことで最終的に以下のような動き方になります。

- 引数にmatcher, messageをとることができる
- ブロックをとることができる
- ExpectationTargetクラスを生成した時にセットされたtargetと一緒に引数を後続処理に送る
- matcherのmatchメソッドを実行


```ruby:rspec-expectations/expectation_target.rb
attr_reader :target

# @api private
def initialize(value)
  @target = value
end

…

def to(matcher=nil, message=nil, &block)
  prevent_operator_matchers(:to) unless matcher
  RSpec::Expectations::PositiveExpectationHandler.handle_matcher(target, matcher, message, &block)
end
```

```ruby:rspec-expectations/handler.rb
def self.handle_matcher(actual, initial_matcher, custom_message=nil, &block)
  ExpectationHelper.with_matcher(self, initial_matcher, custom_message) do |matcher|
    return ::RSpec::Matchers::BuiltIn::PositiveOperatorMatcher.new(actual) unless initial_matcher

    match_result = matcher.matches?(actual, &block)
    if custom_message && match_result.respond_to?(:error_generator)
      match_result.error_generator.opts[:message] = custom_message
    end

    match_result || ExpectationHelper.handle_failure(matcher, custom_message, :failure_message)
  end
end
```

この時点でmatcherのmatchメソッドが結果を返し、これが呼び出し元に返されていることから
expectationはworld構成後のステップで随時呼び出されるものであることが推測されます。
実際にbyebugをテストコードに差し込んでみます。

```ruby:someclass_spec.rb
require './someclass'
require 'byebug'

RSpec.describe('SomeClass') {
  let(:kore) { are }
  let(:are) { sore }
  describe('initialize') {
    let(:sore) { SomeClass.new }
    subject { kore.color }
    byebug #1
    it {
      byebug #3
      is_expected.to eq 'yellow'
    }
  }
  byebug #2
}
```

実行順が1->2->3の順になる事が確認できます。
Runnerクラスのrunメソッドでsetupとrun_specの間にbyebugを差した場合1->2->@->3の順です。

```ruby:rspec-core/runner.rb*
def run(err, out)
  setup(err, out)

  byebug #@

  return @configuration.reporter.exit_early(exit_code) if RSpec.world.wants_to_quit
  run_specs(@world.ordered_example_groups).tap do
    persist_example_statuses
  end
end
```

expectationはexampleが受け取ったブロックの中に格納されていて単純に下位のモデルのような位置づけですが、より上位のグループとは異なるメカニズムで実行されることが分かりました。

# あとがき

Rspecの世界観ということで、ここまでworldという概念とその構成について調べてきました。

ところでRSpecの世界観といえば例えばdescribeとcontextがまったく同じ動きをするような、叙述的な英語でテストコードを書くという世界観があるようですね。…だからこそ英語の苦手な日本人にとって書き方に困るのだと思うのですが。

その面でも実際にコードを見て「本当にまったく同じじゃん！」というのが目視確認できたのも収穫です。
それにしても実装はいろいろなパターンがあって奥が深いですね…。