describe Sample, 'sample' doがなぜレガシーな書き方と言われるのか?
はじめに
この記事では、describe Sample, 'do_something' do
のようにdescribeの引数が2つある書き方がレガシーとなることについて、調査と結論を記載しました。
なお、結論は推測の結果となっていますので、ご注意ください。
きっかけ
RuboCop RSpecへPull Requestを出したときに、以下コメントを頂きました。
The "additional" docstring is not advertised by RSpec, and for a reason. Retrospectively it's observed as a mistake to have been added in the first place.
「追加の文字列を使えることはRSpecでは明記されていないのですが、それには理由があります。そもそも、その文字列が追加されたこと自体が間違いだったようです。」とのコメントです。
ここでのadditional docstringは、以下サンプルコードでの第2引数(do_something
)を指しているようでした。
describe Sample 'do_something' do
・・・
end
なぜ間違いだったのかと思い、このadditional docstringを調べたところ、RuboCop RSpecでIssueとして挙がっていました。
このコメントを見ると、
RSpec maintainers consider this a legacy:
This should be underneath the primary usage, and should probably be removed at some point, bit of a legacy hangover from having oneliners.
Closing as an obvious anti-pattern.
とあり、「RSpecのメンテナーはdescribe TimeParser, 'parse time' do
のような書き方をレガシーと考えています。アンチパターンとしてIssueをクローズします。」とありました。
RSpecのメンテナーがレガシーと考えていると書いた経緯を知りたかったので、このコメントで引用されていたリンク先を見てみました。
ここでの修正内容と、Pull Requestの作成者とRSpecのメンテナーのやり取りは以下の通りです。
Pull Request作成者による修正とコメント:
define_example_methodメソッドのコメントを追加
# @overload $1
# @overload $1(&example_group_definition)
# @param example_group_definition [Block] The definition of the example group.
# @overload $1(doc_string, additional_doc_string, *metadata_keys, metadata={}, &example_implementation) #←このコメントを追加
・・・
def self.define_example_method(name, extra_options={})
・・・
Example group definition accepts a second docstring argument, but only one:
describe 'MyClass', 'in extreme conditions' do # ... end
とあります。コメントの修正と合わせて考えると、おそらくこのコメントで言いたいのは、
Example groupの定義は2つ目の引数としてdocstringを受け入れますが、define_example_methodメソッドのコメントにはdocstring1つのみ受け入れる定義となっています。
そのため、2つ目の引数を受け入れるコメントを追加しました。
と思われます。
これに対し、RSpecのメンテナーから返信がついていました。
This should be underneath the primary usage, and should probably be removed at some point, bit of a legacy hangover from having oneliners.
これは主な用途の下にあるべきもので、いずれは削除されるべきでしょう。ワンライナーを持つことによるレガシーの名残です。
とあり、describeに対する2つ目のdocstringを1行でまとめて書くことはレガシーだと言われています。
コメントはここまでとなっているのですが、そもそもなぜ1行で書けるようになったのか、なぜレガシーなのか、理由が見当たりませんでした。
そのため、このことについて深堀りしていくことにしました。
先に結論
どちらについても明確な記事がなかったようなので、調査を進めた上での推測になります。
知見ある方がいらっしゃいましたらコメントで教えていただけると幸いです。
なぜ1行で書けるようになったのか
RSpecに導入されたMicronautの初期バージョンリリース時に、1行で書ける仕組みを作っていました。
この仕組みが入った経緯は分かりませんが、Micronaut自体で1行で書くようなスタイルがところどころに見られたため、その影響を受けて1行で書けるようにしたのではないかと思われます。
なぜレガシーな書き方なのか
describeはテストのグループ化をするもの、ネストしたdescribeを使うことでテストを整理できるといったことがあります。
このことから、describeを分割して書くことが主流となり、1行で書くことはレガシーになったと思われます。
調査
現状調査:どのコードによって1行で書けるのか
describe Sample, 'do_something' do
を受け付けるコード(以下、describe構文)を調査します。
この調査には以下記事を参考にしました。
describe
メソッドは以下のコードで呼ばれています。
# 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
describe
メソッドは、RSpec内でdefine_example_group_method
メソッドを使用して動的に作成されるため、このdefine_example_group_method
メソッドを確認します。
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
describe
メソッドの引数(Sample, 'sample'
)は*args
として渡されているので、description
にdescribe構文のSample
がセットされます。
その後のコードでは、metadata
は{}のままなので、args
は['sample']
のままとなり、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
# 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
subclass
メソッドでは、parent
はExampleGroup
となっているので、subclass
はExamleGroup
クラスとなります。
このsubclass
のset_it_up
メソッドでargs
を渡しているので、ExampleGroup
クラスのset_it_up
メソッドを見てみます。
def self.set_it_up(description, args, registration_collection, &example_group_block)
・・・
@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
)
・・・
このset_it_up
メソッドではargs
がMetadata.build_hash_from
メソッド、Metadata::ExampleGroupHash.create
メソッドで使用されているので、それぞれ確認します。
def self.build_hash_from(args, warn_about_example_group_filtering=false)
hash = args.last.is_a?(Hash) ? args.pop : {}
hash[args.pop] = true while args.last.is_a?(Symbol)
if warn_about_example_group_filtering && hash.key?(:example_group)
RSpec.deprecate("Filtering by an `:example_group` subhash",
:replacement => "the subhash to filter directly")
end
hash
end
build_hash_from
メソッドでは、ユーザ定義のメタデータをargs
から取り出します。
ここではargs
が['do_something']
なので、hash
には何もセットされないままとなります。
なお、このhash
がどんな場合にセットされるのかは、本記事の最後にて記載します。
class ExampleGroupHash < HashPopulator
def self.create(parent_group_metadata, user_metadata, example_group_index, *args, &block)
group_metadata = hash_with_backwards_compatibility_default_proc
if parent_group_metadata
group_metadata.update(parent_group_metadata)
group_metadata[:parent_example_group] = parent_group_metadata
end
hash = new(group_metadata, user_metadata, example_group_index, args, block)
hash.populate
hash.metadata
end
create
メソッド内にて、新しく作成したハッシュをgroup_metadata
にセットし、次のnew
メソッドにargs
と一緒に渡しています。
なお、group_metadata
のセット元であるhash_with_backwards_compatibility_default_proc
メソッドについては、ここでは詳しく述べませんが、新しくハッシュを作成する処理となっています。
ExampleGroupHash
クラスではinitialize
メソッドはありませんが、親のHashPopular
にはあるので、そのメソッドを見てみます。
class HashPopulator
・・・
def initialize(metadata, user_metadata, index_provider, description_args, block)
@metadata = metadata
@user_metadata = user_metadata
@index_provider = index_provider
@description_args = description_args
@block = block
end
ここでは、呼び出し元のgroup_metadata
、args
が、それぞれ@metadata
、@description_args
にセットされるのみとなります。
new
メソッドは値のセットのみなので、次のpopulate
メソッドを見てみます。
def populate
ensure_valid_user_keys
metadata[:block] = block
metadata[:description_args] = description_args
metadata[:description] = build_description_from(*metadata[:description_args])
metadata[:full_description] = full_description
metadata[:described_class] = described_class
populate_location_attributes
metadata.update(user_metadata)
end
metadata[:description_args]
にdescription_args
をセットしています。
そのため、ここで[do_something]
がmetadata[:description_args]
にセットされることになります。
なお、このmetadata
は、specの結果表示などに使用されます。
これらのコードにより、describe構文を受け付けています。
1行で書けるようになったのはなぜか
metadata.rbでdescribe構文内での第2引数を受け付けているので、このファイルのログをたどり、どこでこのコードが入ったのかを確認します。
git logで確認したところ、populate
メソッドの処理は、process
メソッドから持ってきています。
その先のログをたどると、process
メソッドでのdescribe構文内での第2引数の受付処理を、example_group.rbのset_it_up
メソッドから移動しています。
example_group.rbのログを見てみると、ファイルのリネーム元がbehaviour.rbとなっていました。
behaviour.rbのログをたどっていくと、以下コミットでbehaviour.rbにdescribe構文内の引数2つが受け付けられるようになっていました。
def self.set_it_up(*args)
@metadata = { }
extra_metadata = args.last.is_a?(Hash) ? args.pop : {}
extra_metadata.delete(:behaviour) # Remove it when present to prevent it clobbering the one we setup
@metadata.update(self.superclass.metadata)
@metadata[:behaviour] = {}
@metadata[:behaviour][:describes] = args.shift unless args.first.is_a?(String)
@metadata[:behaviour][:describes] ||= self.superclass.metadata && self.superclass.metadata[:behaviour][:describes]
@metadata[:behaviour][:description] = args.shift || ''
・・・
end
def self.describe(*args, &behaviour_block)
raise(ArgumentError, "No arguments given. You must a least supply a type or description") if args.empty?
raise(ArgumentError, "You must supply a block when calling describe") if behaviour_block.nil?
subclass('NestedLevel') do
args << {} unless args.last.is_a?(Hash)
args.last.update(:behaviour_block => behaviour_block)
set_it_up(*args)
module_eval(&behaviour_block)
end
end
このコミットログを見ると、Micronautからのマイグレーションとあります。
加えて、RSpecのHistoryにて、
In late 2008, Chad Humphries built Micronaut, a new spec runner, to address these problems. Micronaut also included a new metadata system that provided much greater flexibility than RSpec 1 had.
In early 2010, after the release of RSpec 1.3, David and Chad began working on RSpec 2.
とあり、RSpec 1の問題を解決するものとしてMicronautが挙げられています。
また、2010年にRSpec 2を作り始めた人としてMicronautの作者があります。
そのため、今回の構文がMicronautをベースにしていることが分かります。
MicronautはRSpecとAPI互換を持つ軽量のBDDテストフレームワークですが、開発は2012年で止まっています。
RSpecに統合される前のコードは、Micronautプロジェクトのlib/micronaut/behaviour.rbとなります。
このログをたどると、以下コミットでbehaviour_group.rbからリネームされています。
behavirour_group.rbのログを見ると、以下コミットでbehaviour_group_class_methods.rbから処理を移されています。
behaviour_group_class_methods.rbのログをたどると、以下コミットで、example_group.rbから処理を移しています。
このexample_group.rbの履歴を見ると、もともとはKernelモジュール.describeを呼び出す処理だったようです。なお、このコミットでdescribe
メソッドの引数にdesc
が追加されています。
ここが追加された最初の箇所かと思われましたが、実はその1つ前のコミットで既にdesc
を受け付けるKernelモジュールのdescribe
メソッドが定義してありました。
kernel.rbを追いかけた結果、初期コミットでこの処理が追加されています。
module Micronaut
module Extensions
module Kernel
def describe(name_or_const, desc=nil, &describe_block)
example_group = Micronaut::ExampleGroup.new(name_or_const, desc)
example_group.instance_eval(&describe_block)
Micronaut::ExampleWorld.example_groups << example_group
end
end
end
end
include Micronaut::Extensions::Kernel
この処理が入った経緯やコメントがないので、ログとコードから分かる範囲はここまでとなります。
この結果から、なぜこの書き方が入ったのか、推測してみます。
初期コミットのMicronautのコードを見ると、1行のコードが長くなっても1行で済ませるようなコードや、複数行に分けても良いコードを1行にしているコードが見られました。
(なお、2013年でのRuboCopはデフォルトの1行の文字数が79文字)
この1行で済ませる書き方が、今回のdescribe構文を受け付ける書き方につながったのではと思われます。
なぜレガシーな書き方なのか
1行で書くことはレガシーな書き方であることを、明確に表現している記事は見当たりませんでした。
This should be underneath the primary usage, and should probably be removed at some point, bit of a legacy hangover from having oneliners.
こちらも推測になります。
以下記事からそれぞれ抜粋しました。
describe (RSpec.describe)はテストのグループ化を宣言します。
https://qiita.com/jnchito/items/42193d066bd61c740612
私はネストしたコンテキスト(describeとcontextを使うコード)が、テストを整理するのに便利だと感じています。
https://qiita.com/jnchito/items/3a8d19fd9a30468cafd4
このことから、以下のようにテスト対象のクラスに対し、テストメソッドを整理するためにdescribeを使用するようになってきたと思われます。そのため、1行で書くことはあまり推奨されなくなったのではと思われます。
describe Sample
describe 'do_something'
end
describe 'do_something_other'
end
end
結論
なぜ1行で書けるようになったのかについては、RSpecに統合されたMicronautがもともと1行で書ける仕組みを作っており、Micronaut自体で、1行で書くようなスタイルがところどころに見られたため、その影響を受けたためと考えられます。
この1行の書き方がなぜレガシーな書き方なのかについては、describeはテストのグループ化、ネストしたdescribeを使うことでテストを整理できるということから、1行で書くことはレガシーになったと思われます。
もしこの疑問に対して、知見ある方がいらっしゃいましたらコメントで教えていただけると幸いです。
補足
build_hash_fromメソッドについて
build_hash_from
メソッドは、describe
にハッシュやシンボルをセットしていたときに、その値をmetadataとして保存するメソッドとなります。
以下のRSpecのコードのような場合に、hash
に値をセットします。
RSpec.describe "a group with simple metadata", :fast, :simple, :bug => 73 do
it 'has :fast => true metadata' do |example|
expect(example.metadata[:fast]).to eq(true)
end
it 'has :simple => true metadata' do |example|
expect(example.metadata[:simple]).to eq(true)
end
it 'can still use a hash for metadata' do |example|
expect(example.metadata[:bug]).to eq(73)
end
it 'can define simple metadata on an example', :special do |example|
expect(example.metadata[:special]).to eq(true)
end
end
※以下記事からコードを抜粋しました。
この:fast
、:simple
、:bug => 73
は、そのテストケースのみ使用可能なユーザ定義のメタデータとなります。
テストケースにおいて、ユーザ定義のメタデータ(example.metadata
)を使用することで、:bug
は73
、:fast
や:simple
はtrueとなります。
おわりに
Pull Requestでコメントされていた内容をもとに、describeを使った書き方を調査し、個人での結論を出しました。
今回得た推測の結論とは別に、そこに至るまでの調査で、RSpecで使用できる構文の歴史や別のプロジェクトがRSpecに統合された話などを新たに知識として得ることができました。
この記事が誰かのお役に立てれば幸いです。
Discussion