これは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_groups
はRSpec.world
によってフィルタされ整列済みになっている
テストケース定義
RSpec::Core::Configuration
がload_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
を作るメソッドを定義する -
describe
やxdescribe
、context
等、複数の名前で定義するため - テストケースの実体は
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とかで止めると見れる数字付の自動生成されたクラス名がそれ - 最後に
let
やsubject
の定義場所になるモジュールを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
-
it
やspecify
の実体となるメソッド -
describe
と同じく別名で複数登録するためメソッドでラップされている -
ExampleGroup.examples
にExample
のインスタンスを登録している - 実はテストケースが実際に実行されるのは
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というクラスがテスト失敗のメッセージを表示している。
先程、テストケースの実行で解説したExampleGroup
のself.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
を通じてFormatter
にNotification
を送る - 例えばテストが失敗した時は、
Formatter
がそのイベントを受け取り、Notification
に格納された情報を元にfailure messageを表示する
というわけで、RSpecの裏側の基本的な処理の流れについて、ざっくりと書いてみました。
誰がこんなの最後まで読むんだ、という感じであんまりRSpecの覇権を再び!みたいな感じにはならないような気もする…。
弄る可能性が高いのは、テストケースを動的に定義する場合とカスタムFormatterなので、その辺りに集中して読んでみるとRSpecを拡張したり、gemで引っかかっても読む手掛かりができるかもしれません。