LoginSignup
3
1

More than 3 years have passed since last update.

Rspecの世界観を読む

Last updated at Posted at 2021-04-07

この記事は

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

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

Rspec、一言いうと「やることは少ないけど理解は難しい」という感じがしましたね…。
まず公式ドキュメント…公式ってこれだけですよね?

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

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

  • describe
  • it
  • expect / it_expected

の実装と動作メカニズムまでが範囲です。

Hello RSpec.world!

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

  • :configuration
  • :world

rspec-core/core.rb

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にある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になります。

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するかどうからしいです。それって実際何?
コードとここを読む感じだと、activate_bin_pathは新しめのバージョンで追加されたbinstab用のメソッドらしいですが。

rspec-core/exe/rspec
先ほどのコマンド実行用エントリポイントからloadされるファイルです。

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

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

rspec-core/core.rb -> rspec-support/support.rb -> rspec-core各種ライブラリの順に読み込んで
rspec-core/runnerにあるRunnerクラスのinvokeが実行されています。ライブラリの読み込みは以下のように進みます。

rspec-core/core.rb
require "rspec/support"
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
rspec-core/core.rb
RSpec::Support.define_optimized_require_for_rspec(:core) { |f| require_relative f }

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

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が登場します。

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

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

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つの処理を行っています。

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にソースがあって

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つのコードが実行する内容は同じ。

__send__(:load, file)
load(file)

読み込んだ後に実行されているrun_specはrspec-core/runner.rbの中にありました。

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の結果から推測して追跡します。

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

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

someclass.rb
class SomeClass
  attr_reader :color
  attr_accessor :shape

  def initialize
    @color = 'yellow'
    @shape = 'square'
  end

  def do_awesome
    @shape = 'cube'
  end
end
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を使ってrspec-core/example_groups.rbの中で作られています。

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
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
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を使って直に見てみます。

someclass_spec.rb
require './someclass'
require '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を仕掛けて小さな世界をダンプしてみます。

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) RSpec::ExampleGroups::SomeClass::Initialize.methods

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

byebug
(byebug) RSpec::ExampleGroups::SomeClass::Initialize.instance_methods

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

context, example_group

rspec-core/example_groups.rbを見直すと、example_groupsとcontextがdescribeと完全に同じ手順でメソッド定義されている分かりますね。

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) RSpec::ExampleGroups::SomeClass::Initialize.examples
[#<RSpec::Core::Example "example at ./someclass_spec.rb:10">]

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

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が全く同じメソッド。

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扱いになるみたいですね。
書き途中ならブロックをコメントアウトすればいいのかな。

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

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

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から辿って

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のrunを実行します。

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の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で確認可能です。

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

expectはrspec-expectations/syntax.rbrspec-expectations/expectation_target.rbに実装されていて

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

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

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

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

someclass_spec.rb
it {
  is_expected.to eq 'yellow'
}

または

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

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

  • 引数にmatcher, messageをとることができる
  • ブロックをとることができる
  • ExpectationTargetクラスを生成した時にセットされたtargetと一緒に引数を後続処理に送る
  • matcherのmatchメソッドを実行
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
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をテストコードに差し込んでみます。

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の順です。

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がまったく同じ動きをするような、叙述的な英語でテストコードを書くという世界観があるようですね。…だからこそ英語の苦手な日本人にとって書き方に困るのだと思うのですが。

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

3
1
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
3
1