Ruby
RSpec
RubyDay 1

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

More than 3 years have passed since last update.

これは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で引っかかっても読む手掛かりができるかもしれません。