Movatterモバイル変換


[0]ホーム

URL:


LoginSignup
372

Go to list of users who liked

374

Share on X(Twitter)

Share on Facebook

Add to Hatena Bookmark

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated atPosted at 2014-12-01

これは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=worldend
defrun(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_groupsRSpec.worldによってフィルタされ整列済みになっている

テストケース定義

RSpec::Core::Configurationload_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を作るメソッドを定義する
  • describexdescribecontext等、複数の名前で定義するため
  • テストケースの実体は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)subclassend
  • set_it_upメソッドでdescriptionやメタデータをクラス情報として組み込み、Worldに登録する
  • assign_constで動的に生成したクラス名にサブクラスを割り当てる。pryとかで止めると見れる数字付の自動生成されたクラス名がそれ
  • 最後にletsubjectの定義場所になるモジュールを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.lastendend
  • itspecifyの実体となるメソッド
  • describeと同じく別名で複数登録するためメソッドでラップされている
  • ExampleGroup.examplesExampleのインスタンスを登録している
  • 実はテストケースが実際に実行されるのは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)endendend

example_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)endend

rspec/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,linenoend
  • evalを使って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)endendend

rspec/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)end
  • toメソッドは引数にマッチャオブジェクトを受け取ってハンドラーを呼ぶ。

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,:eq

rspec/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_withmessageendendend

rspec/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というクラスがテスト失敗のメッセージを表示している。

先程、テストケースの実行で解説したExampleGroupself.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)# テストが終わった時のメッセージ表示end

rspec/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)end
defnotify(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_failed
defexample_passed(passed)# passedはNotificationのインスタンスoutput.putspassed_output(passed.example)end
defpassed_output(example)ConsoleCodes.wrap("#{current_indentation}#{example.description.strip}",:success)end
  • Formatters.registerを呼び出して、どのイベントを受け取るかを宣言する
  • 各イベントに対応したメソッドはNotificationのインスタンスから情報を得て、メッセージを出力したり情報を保存したりする

情報を蓄積するタイプのサンプルとしてrspec_junit_formatterを見てみよう。

defexample_passed(notification)@example_notifications<<notificationenddefexample_pending(notification)@example_notifications<<notificationenddefexample_failed(notification)@example_notifications<<notificationend
defdump_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を通じてFormatterNotificationを送る
  • 例えばテストが失敗した時は、Formatterがそのイベントを受け取り、Notificationに格納された情報を元にfailure messageを表示する

というわけで、RSpecの裏側の基本的な処理の流れについて、ざっくりと書いてみました。
誰がこんなの最後まで読むんだ、という感じであんまりRSpecの覇権を再び!みたいな感じにはならないような気もする…。

弄る可能性が高いのは、テストケースを動的に定義する場合とカスタムFormatterなので、その辺りに集中して読んでみるとRSpecを拡張したり、gemで引っかかっても読む手掛かりができるかもしれません。

372

Go to list of users who liked

374
0

Go to list of comments

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
372

Go to list of users who liked

374

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?


[8]ページ先頭

©2009-2025 Movatter.jp