この記事は
テストライブラリは毎回自分で書いていた私です。
テストのレビュワーがいない仕事で生きていたのでそれで良かったのですが、
標準的な書き方でもかけたほうが良いだろうということでRSpecを使ってみました。
Rspec、一言いうと「やることは少ないけど理解は難しい」という感じがしましたね…。
まず公式ドキュメント…公式ってこれだけですよね?
ひとまず使えるRSpec入門を参考にさせていただきまして、そもそもRSpecってどう動いているのかをコードで追ってみました。
コードを読むことで設計とともにRSpec的世界観が見えた気がしたので、それを書き留めるのが今回の趣旨です。
分量が多いので2回か3回に分けて文書化しようと思います。今回はRSpecの世界観の核を構成している
- describe
- it
- expect / it_expected
の実装と動作メカニズムまでが範囲です。
Hello RSpec.world!
根幹にあたるRspecクラスには以下の2つのattributeが定義されています。
- :configuration
- :world
module RSpec
autoload :SharedContext, 'rspec/core/shared_context'
extend RSpec::Core::Warnings
class << self
# Setters for shared global objects
# @api private
attr_writer :configuration, :world
end
結論から言えば、RSpecはこのworld上に、大きな方から以下のような概念をネストしてテストを実行してます。
- example_group
- example
- expectation
rspec-core/runner.rbにあるRunnerクラスのインスタンスは以下の順で動作します。
- RSpecのコードを処理しながらworldを組み立てる
- world内のexpectationが満たされているか実際にチェックする
- 結果のレポートを書き出す
let, subjectやbefore, afterなどのフックも一度worldの中にプログラムされることで遅延実行を実現しているようです。
エントリポイントからテスト完了まで
gemのbinディレクトリにあるrspecをコマンドラインから実行するところからコードを追いかけてみます。
ruby3.0x環境で./vendorにbundle installしました。
同じ手順ならエントリポイントのパスは./vendor/ruby/3.0.0/bin/rspecになります。
# !/usr/bin/env ruby
…
if Gem.respond_to?(:activate_bin_path)
load Gem.activate_bin_path('rspec-core', 'rspec', version)
else
gem "rspec-core", version
load Gem.bin_path("rspec-core", "rspec", version)
end
rspec-coreのrspecを読み込むことでテストが開始するようです。
※bin_pathとactivate_bin_pathの違いは対象のGemをactivateするかどうからしいです。それって実際何?
コードとここを読む感じだと、activate_bin_pathは新しめのバージョンで追加されたbinstab用のメソッドらしいですが。
rspec-core/exe/rspec
先ほどのコマンド実行用エントリポイントからloadされるファイルです。
# !/usr/bin/env ruby
require 'rspec/core'
RSpec::Core::Runner.invoke
rspec-core/core.rb -> rspec-support/support.rb -> rspec-core各種ライブラリの順に読み込んで
rspec-core/runnerにあるRunnerクラスのinvokeが実行されています。ライブラリの読み込みは以下のように進みます。
require "rspec/support"
def self.define_optimized_require_for_rspec(lib, &require_relative)
name = "require_rspec_#{lib}"
if RUBY_PLATFORM == 'java' && !Kernel.respond_to?(:require)
# JRuby 9.1.17.0 has developed a regression for require
(class << self; self; end).__send__(:define_method, name) do |f|
Kernel.send(:require, "rspec/#{lib}/#{f}")
end
elsif Kernel.respond_to?(:require_relative)
(class << self; self; end).__send__(:define_method, name) do |f|
require_relative.call("#{lib}/#{f}")
end
else
(class << self; self; end).__send__(:define_method, name) do |f|
require "rspec/#{lib}/#{f}"
end
end
end
RSpec::Support.define_optimized_require_for_rspec(:core) { |f| require_relative f }
環境のruby自体の実装ごとに読み込み方を変えているのでしょうか?
Runnerクラスが呼び出せるようになって、先ほどのRunner.invokeが動きます。rspec-core/runner.rbを見ると
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
ここでコマンドライン引数をoptionsに格納して、runnerインスタンスを作成。
作成されるインスタンスは以下のように初期化され、ここでworldが登場します。
def initialize(options, configuration=RSpec.configuration, world=RSpec.world)
@options = options
@configuration = configuration
@world = world
end
Runnerのインスタンスメソッドのrunは
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
setup → run_specsの2つの処理を行っています。
def setup(err, out)
configure(err, out)
return if RSpec.world.wants_to_quit
@configuration.load_spec_files
ensure
@world.announce_filters
end
configurationsは先ほどRSpec.configurationがデフォルト引数に入っていましたが、これは
rspec-core/configuration.rbにソースがあって
def load_spec_files
…
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
…
def load_file_handling_errors(method, file)
__send__(method, file)
rescue LoadError => ex
…
end
ソース中のコメントも手掛かりに解釈すると、取得したspecファイルのパスに対してテストコードを先頭から読んでいます。
※以下の2つのコードが実行する内容は同じ。
__send__(:load, file)
load(file)
読み込んだ後に実行されているrun_specはrspec-core/runner.rbの中にありました。
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
引数example_groupsは呼び出し元で@world.ordered_example_groupsとなっており、world全体のexample_groupsです。
これをmapして木構造にネストされたexample_groupを順番に精査していくようです。
ここまで冒頭に書いた以下のようなRunnerの動きを実際に追ってみました。
- RSpecのコードを処理しながらworldを組み立てる
- world内のexpectationが満たされているか実際にチェックする
- 結果のレポートを書き出す
ここからRSpecの文法に従ってコーディングしたテストがどのようにworldを形成して実行されるのかを調べていきます。
テストコードとworldの構造
ここからはbyebugも併用して、コードが読みにくい部分はbyebugの結果から推測して追跡します。
確認用のコード(ベース)
確認のために試験対象のクラスと試験用のコードを準備します。
以下のコードに手を加えるような形で検証していきます。
class SomeClass
attr_reader :color
attr_accessor :shape
def initialize
@color = 'yellow'
@shape = 'square'
end
def do_awesome
@shape = 'cube'
end
end
require './someclass'
require 'byebug'
RSpec.describe 'SomeClass' {
let(:kore) { are }
let(:are) { sore }
describe 'initialize' {
let(:sore) { SomeClass.new }
subject { kore.color }
it {
is_expected.to eq 'yellow'
}
}
}
テストコードのメソッドが何をしているのかの概要は冒頭の参考URLに詳しいので、こちらでは説明を省略します。
describeが作る小さな世界
Rspecのコードを書くときは十中八九、RSpec.describeからスタートするのではないでしょうか?
この重要なdescribeというメソッド、一般にグループを作るとされていますが一体何をしているんでしょうか?
describeのメソッド定義はソースコードの中にはありません。(これが今回結構読むのに苦労しました…)
最初から定義されているわけではなく、rspec-core/dsl.rbを使ってrspec-core/example_groups.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
# An alias of `example_group`. Generally used when grouping examples
# contextually (e.g. "with xyz", "when xyz" or "if xyz").
# @see example_group
define_example_group_method :context
def self.define_example_group_method(name, metadata={})
idempotently_define_singleton_method(name) do |*args, &example_group_block|
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
end
RSpec::Core::DSL.expose_example_group_alias(name)
end
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|
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
途中の以下の部分でnameに:describeが与えられることでメソッドとして使える状態になる、という実装でした。
可変長引数とブロックを取ってExampleGroupクラスのインスタンスを生成するメソッドとして生成されていますね。
__send__(:define_method, name) doブロック
ruby力が足りず、どうしてDSLでdefine_moduleしたメソッドがExampleGroupクラスで使えるのかなど解読不能な個所が多いコードでしたが、こんな時は実際にdescribeすると何が起きるのかbyebugを使って直に見てみます。
require './someclass'
require 'byebug'
byebug
(byebug) RSpec.describe
RSpec::ExampleGroups::Anonymous
(byebug) RSpec.describe
RSpec::ExampleGroups::Anonymous_2
(byebug) RSpec.describe 'some_class'
RSpec::ExampleGroups::SomeClass
(byebug) s = RSpec.describe 'some_thing'
RSpec::ExampleGroups::SomeThing
(byebug) s.class
Class
(byebug) s.name
"RSpec::ExampleGroups::SomeThing"
(byebug) s.describe
RSpec::ExampleGroups::SomeThing::Anonymous
こんな感じでdescribeはExampleGroups名前空間に新しいクラスを生成し、そのクラスもまたdescribeを使って自身の名前空間上に新しいクラスを作ることができるものだということが見えてきます。
もう一つ観察してみます。この小さな世界には何があるのかです。テストコードを実行してworldが出来上がった後でbyebugを仕掛けて小さな世界をダンプしてみます。
require './someclass'
require 'byebug'
RSpec.describe('SomeClass') {
let(:kore) { are }
let(:are) { sore }
describe('initialize') {
let(:sore) { SomeClass.new }
subject { kore.color }
it {
is_expected.to eq 'yellow'
}
}
}
byebug
(byebug) RSpec::ExampleGroups::SomeClass::Initialize.methods
長いので省略しますが:parent_groupsや:subclassのようなExampleGroupの上下関係を示すためのメソッドや、検証対象を示す:examples、before, afterでのフックイベントを示す:hookなんかが気になります。また
(byebug) RSpec::ExampleGroups::SomeClass::Initialize.instance_methods
を確認すると、:are, :kore, :soreといった明らかに元々あったとは思えないメソッドが登録されていて、作られた小さな世界=example_groupの中に、expectationを検証するためのツールが詰め込まれていることが分かりました。
context, example_group
rspec-core/example_groups.rbを見直すと、example_groupsとcontextがdescribeと完全に同じ手順でメソッド定義されている分かりますね。
define_example_group_method :example_group
… # コメント部分
define_example_group_method :describe
… # コメント部分
define_example_group_method :context
itで検証単位を作る
ところでRSpec::ExampleGroupの下位クラスであるRSpec::ExampleGroups::SomeClass::Initializeは
先ほどの検証中にexamplesメソッドを持っていて以下のような結果を返していました。
(byebug) RSpec::ExampleGroups::SomeClass::Initialize.examples
[#<RSpec::Core::Example "example at ./someclass_spec.rb:10">]
10行目となるとitから始まる行ですので、恐らくitが検証対象の処理を:examplesの中に登録しているのでしょう。rspec-core/example_groups.rbをもう一度見直してみます
# Defines an example within a group.
define_example_method :example
# Defines an example within a group.
# This is the primary API to define a code example.
define_example_method :it
# Defines an example within a group.
# Useful for when your docstring does not read well off of `it`.
# @example
# RSpec.describe MyClass do
# specify "#do_something is deprecated" do
# # ...
# end
# end
define_example_method :specify
このパターンはdescribeの時に見ました。itとexampleとspecifyが全く同じメソッド。
def self.define_example_method(name, extra_options={})
idempotently_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)
RSpec::Core::Example.new(self, desc, options, block)
end
end
脇道に話がそれますが、itの後ろにブロックがなかった場合はテスト未実装という形でpending扱いになるみたいですね。
書き途中ならブロックをコメントアウトすればいいのかな。
options.update(:skip => RSpec::Core::Pending::NOT_YET_IMPLEMENTED) unless block
話を戻して。
rspec-core/example.rbにあるExampleクラスのインスタンスを生成し、その生成側でexample_groupの:examplesに自身をセットしています。
def initialize(example_group_class, description, user_metadata, example_block=nil)
@example_group_class = example_group_class
@example_block = example_block
…
example_group_class.examples << self
…
end
exampleに渡されたブロックがいつ実行されるのかは気になります。
想定ではworldを組み立てた後でexample_groupごとに実行されるはずですが、これはrspec-core/runner.rbから辿って
def run(err, out)
…
run_specs(@world.ordered_example_groups).tap do
persist_example_statuses
end
end
…
def run_specs(example_groups)
…
example_groups.map { |g| g.run(reporter) }.all?
…
end
rspec-core/example_groups.rbのrunを実行します。
def self.run(reporter=RSpec::Core::NullReporter)
return if RSpec.world.wants_to_quit
reporter.example_group_started(self)
should_run_context_hooks = descendant_filtered_examples.any?
begin
run_before_context_hooks(new('before(:context) hook')) if should_run_context_hooks
result_for_this_group = run_examples(reporter)
results_for_descendants = ordering_strategy.order(children).map { |child| child.run(reporter) }.all?
result_for_this_group && results_for_descendants
…
end
…
def self.children
@children ||= []
end
トップレベルから入る時だけrunnerのrun_specsでループ処理をしていますが、そのあとはrspec-core/example_groups.rbのrunメソッドに移譲されています。直下のexamplesがあれば検証して下位のexample_groupがあれば下位クラスのrunに入ってループするような流れです。
expectationはworldを構成しない
example_group
- describe
- context
- example_group
example
- it
- specify
- example
はworldを構成するメソッドで、worldないし下位の小さな世界に属するオブジェクト作ることが分かりました。
expectやis_expectedはどうでしょう?
is_expectedはexpectのラッパーだと言われますがこれはrspec-core/memoized_helpers.rbで確認可能です。
def is_expected
expect(subject)
end
expectはrspec-expectations/syntax.rbとrspec-expectations/expectation_target.rbに実装されていて
def expect(value=::RSpec::Expectations::ExpectationTarget::UndefinedValue, &block)
::RSpec::Expectations::ExpectationTarget.for(value, block)
end
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
ValueExpectationTarget.new(value)
end
end
値とブロックを受け取ることができ、以下のように結果を返すように実装されていました。
- 値のみ与えられた: ValueExpectationTargetを返す
- ブロックのみ与えられた: BlockExpectationTargetを返す
- 値とブロックが与えられたか、何も与えられなかった: ArgumentErrorをraiseする
ところでテストコードの実装を考えると
it {
is_expected.to eq 'yellow'
}
または
it {
expect(subject).to eq 'yellow'
}
という形で、直後にto(あるいは、not_to)を呼ぶことになっています。
このメソッドはrspec-expectations/expectation_target.rbにあり、rspec-expectations/handler.rbに渡すことで最終的に以下のような動き方になります。
- 引数にmatcher, messageをとることができる
- ブロックをとることができる
- ExpectationTargetクラスを生成した時にセットされたtargetと一緒に引数を後続処理に送る
- matcherのmatchメソッドを実行
attr_reader :target
# @api private
def initialize(value)
@target = value
end
…
def to(matcher=nil, message=nil, &block)
prevent_operator_matchers(:to) unless matcher
RSpec::Expectations::PositiveExpectationHandler.handle_matcher(target, matcher, message, &block)
end
def self.handle_matcher(actual, initial_matcher, custom_message=nil, &block)
ExpectationHelper.with_matcher(self, initial_matcher, custom_message) do |matcher|
return ::RSpec::Matchers::BuiltIn::PositiveOperatorMatcher.new(actual) unless initial_matcher
match_result = matcher.matches?(actual, &block)
if custom_message && match_result.respond_to?(:error_generator)
match_result.error_generator.opts[:message] = custom_message
end
match_result || ExpectationHelper.handle_failure(matcher, custom_message, :failure_message)
end
end
この時点でmatcherのmatchメソッドが結果を返し、これが呼び出し元に返されていることから
expectationはworld構成後のステップで随時呼び出されるものであることが推測されます。
実際にbyebugをテストコードに差し込んでみます。
require './someclass'
require 'byebug'
RSpec.describe('SomeClass') {
let(:kore) { are }
let(:are) { sore }
describe('initialize') {
let(:sore) { SomeClass.new }
subject { kore.color }
byebug #1
it {
byebug #3
is_expected.to eq 'yellow'
}
}
byebug #2
}
実行順が1->2->3の順になる事が確認できます。
Runnerクラスのrunメソッドでsetupとrun_specの間にbyebugを差した場合1->2->@->3の順です。
def run(err, out)
setup(err, out)
byebug #@
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
expectationはexampleが受け取ったブロックの中に格納されていて単純に下位のモデルのような位置づけですが、より上位のグループとは異なるメカニズムで実行されることが分かりました。
あとがき
Rspecの世界観ということで、ここまでworldという概念とその構成について調べてきました。
ところでRSpecの世界観といえば例えばdescribeとcontextがまったく同じ動きをするような、叙述的な英語でテストコードを書くという世界観があるようですね。…だからこそ英語の苦手な日本人にとって書き方に困るのだと思うのですが。
その面でも実際にコードを見て「本当にまったく同じじゃん!」というのが目視確認できたのも収穫です。
それにしても実装はいろいろなパターンがあって奥が深いですね…。