Go to list of users who liked
Share on X(Twitter)
Share on Facebook
More than 5 years have passed since last update.
これを読むとRSpecの裏側がどうやって動いているのか分かるかもしれないぜ
これはTokyuRuby会議08にて発表した資料を元にQiita向けに再編集したものです。
元々Advent Calendarと共用にしようと思って、どう考えても5分で話せない資料でLTしたのでした。
最初に
RubyのテスティングフレームワークとしてはトップクラスにメジャーなRSpecですが、内側の実装が黒魔術感に溢れていて非常に読み辛い。
そしてカスタマイズするにも学習コストが高いという話を聞きます。
最近「RSpec止めますか、人間(Rubyist)止めますか」みたいな風潮が出ていてバリバリのRSpec派の私としては見過ごせない感じになってきたので、いっちょRSpecがどんな感じで動いてるのかを大まかに解説していくことで、世の中に対して再びRSpecを啓蒙していこうと思うわけです。
この話はrspec-core-3.1.7辺りをベースにしています。
起動
rspecのコマンドエンドポイント
# !/usr/bin/env rubyrequire'rspec/core'RSpec::Core::Runner.invoke- invoke呼んでるだけ。楽勝ですね。
rspec/core/runner.rb (1)
defself.invokedisable_autorun!status=run(ARGV,$stderr,$stdout).to_iexit(status)ifstatus!=0end- 標準入力と標準出力、引数を渡してrunを呼んでるだけ。簡単!
defself.run(args,err=$stderr,out=$stdout)trap_interruptoptions=ConfigurationOptions.new(args)ifoptions.options[:drb]require'rspec/core/drb'beginDRbRunner.new(options).run(err,out)rescueDRb::DRbConnErrorerr.puts"No DRb server is running. Running in local process instead ..."new(options).run(err,out)endelsenew(options).run(err,out)endend- ConfigurationOptionsのnewを読みにいくと大分辛そうに見えるけど、実際はコマンドラインオプションをパースするのが主な役目でそんなに重要ではない
rspec/core/runner.rb (2)
definitialize(options,configuration=RSpec.configuration,world=RSpec.world)@options=options@configuration=configuration@world=worldenddefrun(err,out)setup(err,out)run_specs(@world.ordered_example_groups)end- RSpec::Core::Worldはグローバルに情報を保持しておくための内部用の箱
- example_groupsのリストやテストケース、フィルタ情報を持っている
- テストケースが定義される時は、ここにバンバン登録されていく
rspec/core/runner.rb (3)
defsetup(err,out)@configuration.error_stream=err@configuration.output_stream=outif@configuration.output_stream==$stdout@options.configure(@configuration)@configuration.load_spec_files@world.announce_filtersend- RSpec::Core::Configurationに設定を保持する
load_spec_filesでスペックを読み込み- コマンドラインオプションで渡された読み込みパターンに沿って読み込み対象のspecファイルを
loadする - この時点で定義されたテストケースが
RSpec::Core::Worldに登録されている
rspec/core/runner.rb (4)
defrun_specs(example_groups)@configuration.reporter.report(@world.example_count(example_groups))do|reporter|beginhook_context=SuiteHookContext.new@configuration.hooks.run(:before,:suite,hook_context)example_groups.map{|g|g.run(reporter)}.all??0:@configuration.failure_exit_codeensure@configuration.hooks.run(:after,:suite,hook_context)endendend- 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)
defself.define_example_group_method(name,metadata={})define_singleton_method(name)do|*args,&example_group_block|# ...description=args.shiftcombined_metadata=metadata.dupcombined_metadata.merge!(args.pop)ifargs.last.is_a?Hashargs<<combined_metadatasubclass(self,description,args,&example_group_block).tapdo|child|children<<childend# ...RSpec::Core::DSL.expose_example_group_alias(name)end- クラスメソッドとして
ExampleGroupを作るメソッドを定義する describeやxdescribe、context等、複数の名前で定義するため- テストケースの実体は
ExampleGroupのsubclassを定義している
rspec/core/example_group.rb (2)
サブクラスの作り方
defself.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)ifexample_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)subclassendset_it_upメソッドでdescriptionやメタデータをクラス情報として組み込み、Worldに登録するassign_constで動的に生成したクラス名にサブクラスを割り当てる。pryとかで止めると見れる数字付の自動生成されたクラス名がそれ- 最後に
letやsubjectの定義場所になるモジュールをincludeさせる
describeの内側は単なるクラス定義
Refinmentが使えるし、独自のモジュールをincludeすることもできる。
各テストケースの定義
rspec/core/example_group.rb
defself.define_example_method(name,extra_options={})define_singleton_method(name)do|*all_args,&block|desc,*args=*all_argsoptions=Metadata.build_hash_from(args)options.update(:skip=>RSpec::Core::Pending::NOT_YET_IMPLEMENTED)unlessblockoptions.update(extra_options)examples<<RSpec::Core::Example.new(self,desc,options,block)examples.lastendenditやspecifyの実体となるメソッドdescribeと同じく別名で複数登録するためメソッドでラップされているExampleGroup.examplesにExampleのインスタンスを登録している- 実はテストケースが実際に実行されるのは
Exampleのコンテキストではない
テスト実行
rspec/core/runner.rb (再)
defrun_specs(example_groups)@configuration.reporter.report(@world.example_count(example_groups))do|reporter|beginhook_context=SuiteHookContext.new@configuration.hooks.run(:before,:suite,hook_context)example_groups.map{|g|g.run(reporter)}.all??0:@configuration.failure_exit_codeensure@configuration.hooks.run(:after,:suite,hook_context)endendendexample_groups.map { |g| g.run(reporter) }に注目。
rspec/core/example_group.rb (1)
defself.run(reporter)# ...reporter.example_group_started(self)begininstance=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# ...ensureinstance=new('after(:context) hook')run_after_context_hooks(instance)# 再度別のインスタンスを作りafter(:context)フックを実行before_context_ivars.clearreporter.example_group_finished(self)endendrspec/core/example_group.rb (2)
defself.run_examples(reporter)ordering_strategy.order(filtered_examples).mapdo|example|nextifRSpec.world.wants_to_quitinstance=new(example.inspect_output)# テストケース実行のためのインスタンスを作るset_ivars(instance,before_context_ivars)# before(:context)で定義したインスタンス変数を動的に定義するsucceeded=example.run(instance,reporter)# ExampleGroupのインスタンスを渡しているRSpec.world.wants_to_quit=trueiffail_fast?&&!succeededsucceededend.all?end- 実は
before(:context)/after(:context)と各テストケースを評価しているコンテキストは別だったりする - 何故インスタンス変数が引き回せるのかというと、
ExampleGroupのクラスレベルでインスタンス変数を保存しており、テストケース実行時にインスタンスが作成される度にインスタンス変数を勝手に再起動している。中々無茶である。 example.run(instance, reporter)にExampleGroupのインスタンスが渡されているのがポイント
rspec/core/example.rb
defrun(example_group_instance,reporter)# ...beginrun_before_example@example_group_instance.instance_exec(self,&@example_block)# ...rescueException=>eset_exception(e)ensurerun_after_exampleendendend# ...end@example_group_instance.instance_exec(self, &@example_block)に注目。- 評価コンテキストは
ExampleGroupのインスタンスである - テストが実行されるとインスタンスはクリアされる
- そのため、後から実行時の情報は取得できない
各テストケースはExampleGroupのメソッド呼び出しみたいなもの
つまりdescribeのブロック内でインスタンスメソッド等を定義すると、各テストケースで利用可能になるし、インスタンス変数、クラス変数がイメージ通りに動作する。
テストケースのグループ定義と各テストケースがクラスとインスタンスの関係になっているのは、結構分かりやすいと思う。
Exampleというクラスもあって、名前的に分かりにくいのだが、これはテストケースのメタデータを持っている箱に過ぎない。
動的にテストケースを定義するにはどうするか
私の作った、rspec-power_assertから一部コードを抜粋してみる。
defit_is_asserted_by(description=nil,&blk)file,lineno=blk.source_locationcmd=description?"it(description)":"specify"eval%{#{cmd} do evaluate_example("#{__callee__}", &blk) end},binding,file,linenoendevalを使ってitを呼ぶのが楽- この時、file名と行数を指定しておくこと
Exampleはインスタンス化された時のブロックの位置を保存しており、実行時の行指定フィルターに利用するため- evalで行数をごまかさないと、行指定フィルターが上手く動作しなくなるので注意
マッチャ
rspec-expectationsという分離されたgemによって定義されている。
expect
rspec/expectations/syntax.rb
defenable_expect(syntax_host=::RSpec::Matchers)returnifexpect_enabled?(syntax_host)syntax_host.module_execdodefexpect(value=::RSpec::Expectations::ExpectationTarget::UndefinedValue,&block)::RSpec::Expectations::ExpectationTarget.for(value,block)endendendrspec/expectations/expectation_target.rb
defself.for(value,block)ifUndefinedValue.equal?(value)unlessblockraiseArgumentError,"You must pass either an argument or a block to `expect`."endBlockExpectationTarget.new(block)elsifblockraiseArgumentError,"You cannot pass both an argument and a block to `expect`."elsenew(value)endend- まず、ExpectationTargetというラップ用のクラスでテスト対象を包む。
expect(foo).to
defto(matcher=nil,message=nil,&block)prevent_operator_matchers(:to)unlessmatcherRSpec::Expectations::PositiveExpectationHandler.handle_matcher(@target,matcher,message,&block)endtoメソッドは引数にマッチャオブジェクトを受け取ってハンドラーを呼ぶ。
RSpec::Expectations::PositiveExpectationHandler
defself.handle_matcher(actual,initial_matcher,message=nil,&block)matcher=ExpectationHelper.setup(self,initial_matcher,message)return::RSpec::Matchers::BuiltIn::PositiveOperatorMatcher.new(actual)unlessinitial_matchermatcher.matches?(actual,&block)||ExpectationHelper.handle_failure(matcher,message,:failure_message)end- ポイントは一番下の行
- マッチャインスタンスの
matches?がfalseになる時、失敗メッセージの構築を行ってそれを返す - つまりマッチャとは何かというと、基本的には
matches?メソッドが生えてて、何か比較してtrueかfalseを返すもの。
マッチャの例
rspec/matchars.rb
defeq(expected)BuiltIn::Eq.new(expected)endalias_matcher:an_object_eq_to,:eqalias_matcher:eq_to,:eqrspec/matches/built_in/eq.rb
classEq<BaseMatcherdeffailure_message"\nexpected:#{format_object(expected)}\n got:#{format_object(actual)}\n\n(compared using ==)\n"end# ...privatedefmatch(expected,actual)actual==expectedend# ...end- マッチャはマッチ処理だけでなく失敗した時にどういうメッセージが必要かという情報も保持している。
- 自分でマッチャを作りたい時は、ビルトインの組込みマッチャを見ると、そんなに難しくない。
- 呼び出し方のルールは決まっているので、基底クラスを継承して必要なメソッドだけ再定義すればオーケー
- マッチャのDSLは単にマッチャオブジェクトを作るためのコンストラクタに過ぎない
マッチャ処理が失敗した時
rspec/expectations/handler.rb
先程、マッチャオブジェクトのmatches?が失敗した時に実行される失敗処理の続きが以下。
moduleExepectationHelper# ...defself.handle_failure(matcher,message,failure_message_method)message=message.callifmessage.respond_to?(:call)# カスタムメッセージが渡されていればそちらを優先message||=matcher.__send__(failure_message_method)# マッチャから失敗時にメッセージを取得ifmatcher.respond_to?(:diffable?)&&matcher.diffable?::RSpec::Expectations.fail_withmessage,matcher.expected,matcher.actualelse::RSpec::Expectations.fail_withmessageendendendrspec/expectations/fail_with.rb
moduleRSpecmoduleExpectationsclass<<self# ...deffail_with(message,expected=nil,actual=nil)unlessmessageraiseArgumentError,"Failure message is nil. Does your matcher define the "\"appropriate failure_message[_when_negated] method to return a string?"enddiff=differ.diff(actual,expected)message="#{message}\nDiff:#{diff}"unlessdiff.empty?# マッチャが失敗すると最終的にこの例外が呼ばれるraiseRSpec::Expectations::ExpectationNotMetError,messageendendendend- マッチャによる比較処理が失敗すると、失敗メッセージをマッチャから取得し、例外を起こすだけ
- 例外によって処理を中断して、
rspec-coreに処理を戻す
fail_messageの表示
例外が起きた後、エラーメッセージはどうやって表示しているのか。
Repoter, Notification, Formatterというクラスがテスト失敗のメッセージを表示している。
先程、テストケースの実行で解説したExampleGroupのself.runメソッドを再び見てみよう。
rspec/core/example_group.rb
defself.run(reporter)# ...begin# ...rescueException=>exRSpec.world.wants_to_quit=trueiffail_fast?for_filtered_examples(reporter){|example|example.fail_with_exception(reporter,ex)}# ...end- reporterは起動時から引数によってずっと引き回されている。
- テスト実行の各工程で通知を受け取りたいFormatterに適宜イベントを送信していく。
- mediatorっぽい役割と考えられる。
rspec/core/example.rb
deffail_with_exception(reporter,exception)start(reporter)set_exception(exception)finish(reporter)# テストが終わった時のメッセージ表示endrspec/core/example.rb
deffinish(reporter)pending_message=execution_result.pending_messageif@exceptionrecord_finished:failedexecution_result.exception=@exceptionreporter.example_failedself# reporterのexample_failedを呼んで失敗したことを通知するfalseelsifpending_messagerecord_finished:pendingexecution_result.pending_message=pending_messagereporter.example_pendingselftrueelserecord_finished:passedreporter.example_passedselftrueendend- テストの各工程でreporterのメソッドを呼んでいるのが分かる。
Reporter
rspec/core/repoter.rb
defexample_failed(example)@failed_examples<<examplenotify:example_failed,Notifications::ExampleNotification.for(example)enddefnotify(event,notification)registered_listeners(event).eachdo|formatter|formatter.__send__(event,notification)# イベント名を使ってFormatterのメソッドを呼び出すendend- Notificationはイベントに対応した情報やメッセージを格納している箱
- formatterはsendを受け取るためにイベント名に対応したメソッドを実装しておく必要がある
- 各テストケースの情報はnotificationを経由して取得する
Formatter
rspec/core/formatters/documentation_formatter.rb
moduleRSpecmoduleCoremoduleFormatters# @privateclassDocumentationFormatter<BaseTextFormatterFormatters.registerself,:example_group_started,:example_group_finished,:example_passed,:example_pending,:example_faileddefexample_passed(passed)# passedはNotificationのインスタンスoutput.putspassed_output(passed.example)enddefpassed_output(example)ConsoleCodes.wrap("#{current_indentation}#{example.description.strip}",:success)endFormatters.registerを呼び出して、どのイベントを受け取るかを宣言する- 各イベントに対応したメソッドは
Notificationのインスタンスから情報を得て、メッセージを出力したり情報を保存したりする
情報を蓄積するタイプのサンプルとしてrspec_junit_formatterを見てみよう。
defexample_passed(notification)@example_notifications<<notificationenddefexample_pending(notification)@example_notifications<<notificationenddefexample_failed(notification)@example_notifications<<notificationenddefdump_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.testsuitetestsuite_optionsdoxml.properties@example_notifications.eachdo|notification|send:"dump_summary_example_#{notification.example.execution_result[:status]}",notificationendendend- 各イベントでは何も表示せずに
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で引っかかっても読む手掛かりができるかもしれません。
Register as a new user and use Qiita more conveniently
- You get articles that match your needs
- You can efficiently read back useful information
- You can use dark theme
