rspecを読んでみる
業務で使っているが、ちょっと変わったことをしようとするたびに挙動が謎で都度ハマっておりつらい。
そこでちょっとコードを読んでみることで裏で起こっていることを知ってみようという試み。
対象はこれ
なお読んでいるコードは現時点の最新バージョンであるv3.10.1
READMEをざっと読むとごく基本的な使い方などが書いてある。が、少々気になる記述を発見。
Example Group: ... The block is evaluated in the context of a subclass of RSpec::Core::ExampleGroup,
Example: ... are evaluated in the context of an instance of the example group class to which the example belongs.
その下の例にもあるが、context/describeはクラス定義でexampleはインスタンスメソッドのようなものとのこと。
具体的にどういう処理が動いているのかは後に追うとして、このだけでも諸々のスコープの挙動が少し納得できた。
たまに書き下すと無駄に長くなる処理を即時ヘルパー関数に切り出す事があるが、上記を踏まえるとこんな感じで呼べる:
describe "test" do
def instance
"instance!"
end
it "test instance" do
expect(instance).to eq "instance!"
end
def self.classm
"class!"
end
list = 3.times.map { |i| classm + i.to_s }.each do |str|
it "test #{str}" do
expect(str).to eq "class!1"
end
end
end
test instance
とtest class!1
がPASS、test class!0
とtest class!2
がFAILになる。
とはいえ突然def self.xxx
が登場するとパッと見で何だそれって感じなので自重すべきか。やるならせめて
classm = -> { "class!" }
list = 3.times.map { |i| classm.call + i.to_s }.each do |str|
のが多少マシか。多少。
さてコードを読む。
本件は特に〇〇の挙動を知りたいとか全体感を把握してパッチしたいとか目的があるわけではないので、とりあえずエントリポイントに突撃。
リポジトリルートを見るとexe/rspec
といういかにもなファイルがあるので覗いてみる。
#!/usr/bin/env ruby
require 'rspec/core'
RSpec::Core::Runner.invoke
ディレクトリを漁るとlib/rspec/core/runner.rb
というそのままのファイルがある。invokeあたりから見てみると
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
def initialize(options, configuration=RSpec.configuration, world=RSpec.world)
@options = options
@configuration = configuration
@world = world
end
...
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
...
def setup(err, out)
configure(err, out)
return if RSpec.world.wants_to_quit
@configuration.load_spec_files
ensure
@world.announce_filters
end
...
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
ざっくり雰囲気でいうと、config類を初期化しつつ@configuration.load_spec_files
し、run_specs
にてexample groupたち&その配下のexampleをrun
しているようだ。
initialize
も合わせて読むと、RSpec.configuration
とRSpec.world
がここでの主な登場人物なもよう。
lib/rspec/core/configuration.rb
もlib/rspec/core/world.rb
もあるのでここらを見ていけばよかろう。
ところで上記のコード、コメントは略したがメソッド定義の並び順はオリジナルのまま。上から順に見ていけばほぼ呼び出し順に並んでいる。超読みやすい。参考にしよう。
先にlib/rspec/core/world.rb
を見てみる。initializeによると
def initialize(configuration=RSpec.configuration)
@wants_to_quit = false
@configuration = configuration
configuration.world = self
@example_groups = []
@example_group_counts_by_spec_file = Hash.new(0)
prepare_example_filtering
end
細かいことを差し置けば
- worldとconfigurationはお互いのインスタンスを保有しており、お互い利用する関係にある(ように見える)
- example_groupsを持っている
あたりがポイントか。というか、
# Internal container for global non-configuration data.
class World
とあるので、configurationと共依存したような構造になっているのは割と納得できる。
これ以外のコードはほぼアクセサ系とメッセージ系。あとreset。
record
とtraverse_example_group_trees_until
はちと気になるが、利用先を見てから戻ってくるくらいでよさそう。
次にlib/rspec/core/configuration.rb
を見るが、長い(2.4k行ほど)ので、Runner.run
で呼ばれているload_spec_files
を見てみる。
# @private
def load_spec_files
# Note which spec files world is already aware of.
# This is generally only needed for when the user runs
# `ruby path/to/spec.rb` (and loads `rspec/autorun`) --
# in that case, the spec file was loaded by `ruby` and
# isn't loaded by us here so we only know about it because
# of an example group being registered in it.
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
worldに既にロードされてるファイルをloadedに入れる(前半)と、そうじゃないやつをloadしつつloadedに入れる(後半)があるもよう。
後半はfiles_to_run
を追うとまぁ色々とあるのだが、要するにconfigで指定されたファイル群をexcludeなどを考慮しつつ列挙しているもよう。
そしてload_file_handling_errors
はエラーハンドリング付きではあるが要するにファイルをloadしている。
loadされるのはいつもの見慣れたspecファイルのはず。ということで、RSpec.describe
あたりを追ってみる。
specファイルでいつも最初に書くRSpec.describe
の定義を追いたいが、パッと見で定義場所がわからないので、適当なspecファイルでbinding.pry
してRSpec.method(:describe).source_location
してみる。
[1] pry(main)> RSpec.method(:describe).source_location
=> ["/usr/local/bundle/gems/rspec-core-3.10.1/lib/rspec/core/dsl.rb", 42]
該当ファイルを見ると
# @private
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| # <= ここがL42
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
RSpec::Core::ExampleGroup
のメソッドをRSpec
に展開するヘルパーのようなものっぽい。このファイル全体でも100行も無いくらいで、処理の実体は特になさそう。
どうもRSpec.describe
の実体はRSpec::Core::ExampleGroup
にありそうなのでそっちを見る。
てか、既存のクラスインスタンスにメソッドを追加する方法すごいな。
これRSpec.define_method(name) do ...
するのと何か違うんだろうか。ruby力が足りずちょっとわからない。そのうち調べよう。
気を取り直してexample_group.rb
を見ると、220行目あたりに
# RSpec.describe "something" do # << This describe method is defined in
# # << RSpec::Core::DSL, included in the
# # << global namespace (optional)
えー。
もう少しexample_group.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
という記述がある。example_group
をファイル内検索しても特に情報は得られないのでdefine_example_group_method
を当たる。246行目に定義がある。
def self.define_example_group_method(name, metadata={})
idempotently_define_singleton_method(name) do |*args, &example_group_block|
...
end
RSpec::Core::DSL.expose_example_group_alias(name)
end
最後にあるRSpec::Core::DSL.expose_example_group_alias
は先程見たので、その上を見る。内部で投げられている例外メッセージ的にこれが本体ではなかろうか。
とはいえ一応idempotently_define_singleton_method
を追う。
# Define a singleton method for the singleton class (remove the method if
# it's already been defined).
# @private
def self.idempotently_define_singleton_method(name, &definition)
(class << self; self; end).module_exec do
remove_method(name) if method_defined?(name) && instance_method(name).owner == self
define_method(name, &definition)
end
end
うーん、まぁ要するにメソッド定義してるだけっぽい。
まとまると、example_group.rb
でRSpec::Core::ExampleGroup
のクラス定義内で
-
define_example_group_method :example_group
を呼ぶ -
idempotently_define_singleton_method
に:example_group
とブロックが渡される -
define_method
される -
RSpec::Core::ExampleGroup
にexample_group
メソッドが生え、定義は2. で渡したブロックとなる
という感じか。describeやcontextも同様にdefine_example_group_method
されているので、今欲しいコードはdefine_example_group_method
定義内にあるブロックだと言える。
RSpec.describe
の実体には辿り着いたので中身を見ていく。define_example_group_method
の中より
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
大まかには「ここで定義するexample_groupを追加すべきexample_groupリストを同定する(前半)」「引数や上記リストを元に、example_group実体(subclass)を定義する」と見える。
registration_collection
の式を見るとなんやかんやでRSpec.world.example_groups
もしくはchildren
が入る。
前者はおそらく見たまんまで、実行するexample_groupたちが収められているのだろう。であればchildrenはdescribe
を呼び出したときの親に相当するgroupの配下にいるgroupsだろうと推測できる。
subclassを読んでいるブロックはほとんどパラメータをゴニョゴニョしているだけなので、そこはあまり気にせずにsubclassメソッドの方を見ていく。
def self.subclass(parent, description, args, registration_collection, &example_group_block)
subclass = Class.new(parent)
subclass.set_it_up(description, args, registration_collection, &example_group_block)
subclass.module_exec(&example_group_block) if example_group_block
Classを作って前処理(set_it_up
)したあとにブロック部分をmodule_execしている。
parentとして渡ってきているのはself
なので、元のdescribe
を読んだときの親もしくはExampleGroupクラスのサブクラスになる。
blockをmodule_execしているだけなので、本当にただのクラス定義といえそう。READMEにある擬似コードはほぼそのままだということがわかった。
※まだit
の定義は追っていないが
set_it_up
の定義はsubclass
定義のすぐ下にある。
def self.set_it_up(description, args, registration_collection, &example_group_block)
# Ruby 1.9 has a bug that can lead to infinite recursion and a ...
ensure_example_groups_are_configured
# Register the example with the group before creating the metadata hash.
# This is necessary since creating the metadata hash triggers
# `when_first_matching_example_defined` callbacks, in which users can
# load RSpec support code which defines hooks. For that to work, the
# examples and example groups must be registered at the time the
# support code is called or be defined afterwards.
# Begin defined beforehand but registered afterwards causes hooks to
# not be applied where they should.
registration_collection << self
@user_metadata = Metadata.build_hash_from(args)
@metadata = Metadata::ExampleGroupHash.create(
superclass_metadata, @user_metadata,
superclass.method(:next_runnable_index_for),
description, *args, &example_group_block
)
config = RSpec.configuration
config.apply_derived_metadata_to(@metadata)
ExampleGroups.assign_const(self)
@currently_executing_a_context_hook = false
config.configure_group(self)
end
registration_collection << self
があるので、やはり呼び出し元で用意していたリストに定義クラスを入れていたようだ。
ちなみに、呼び出し先でリストに破壊的変更させなくても呼び出し元でregistration_collection << subclass(...)
すれば良くない?と思ったが、コメントを読むとちゃんと理由が書いてあった。
あとはMetadataを作ったりconfigurationにもろもろ反映させたりしている。
ここまでで、ざっくりRSpec.describe
で何が起こっているのかはわかった気がする。
RSpec::Support.thread_local_data
やMetadata::ExampleGroupHash
、configurationから呼ぶapply_derived_metadata_to
やconfigure_group
など、深堀りすると良さそうな要素がいくつか残っているが、ひとまずここまで。
ざっくり、rspecを実行するとどんなことが起こるのかや、普段書いているRSpec.describe
とExampleGroupの関係などの全体構成感がわかった。
次はitやlet、before/afterなどを追っていくことになりそう。