📖

describe Sample, 'sample' doがなぜレガシーな書き方と言われるのか?

2021/07/31に公開

はじめに

この記事では、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.

https://github.com/rubocop/rubocop-rspec/pull/1151

「追加の文字列を使えることはRSpecでは明記されていないのですが、それには理由があります。そもそも、その文字列が追加されたこと自体が間違いだったようです。」とのコメントです。

ここでのadditional docstringは、以下サンプルコードでの第2引数(do_something)を指しているようでした。

describe Sample 'do_something' do
・・・
end

なぜ間違いだったのかと思い、このadditional docstringを調べたところ、RuboCop RSpecでIssueとして挙がっていました。
https://github.com/rubocop/rspec-style-guide/issues/63

このコメントを見ると、

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のメンテナーがレガシーと考えていると書いた経緯を知りたかったので、このコメントで引用されていたリンク先を見てみました。
https://github.com/rspec/rspec-core/pull/2681#discussion_r361811453

ここでの修正内容と、Pull Requestの作成者とRSpecのメンテナーのやり取りは以下の通りです。

Pull Request作成者による修正とコメント:
define_example_methodメソッドのコメントを追加

example_group.rb
      #   @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構文)を調査します。
この調査には以下記事を参考にしました。
https://qiita.com/joker1007/items/641961963ff459125f31

describeメソッドは以下のコードで呼ばれています。

example_group.rb
      # 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

https://github.com/rspec/rspec-core/blob/9de11e52d078696c01c3515351e1e634aada1115/lib/rspec/core/example_group.rb#L285

describeメソッドは、RSpec内でdefine_example_group_methodメソッドを使用して動的に作成されるため、このdefine_example_group_methodメソッドを確認します。

example_group.rb
      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

https://github.com/rspec/rspec-core/blob/9de11e52d078696c01c3515351e1e634aada1115/lib/rspec/core/example_group.rb#L246

describeメソッドの引数(Sample, 'sample')は*argsとして渡されているので、descriptionにdescribe構文のSampleがセットされます。
その後のコードでは、metadataは{}のままなので、args['sample']のままとなり、subclassメソッドに渡されます。

example_group.rb
      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

https://github.com/rspec/rspec-core/blob/9de11e52d078696c01c3515351e1e634aada1115/lib/rspec/core/example_group.rb#L395

subclassメソッドでは、parentExampleGroupとなっているので、subclassExamleGroupクラスとなります。
このsubclassset_it_upメソッドでargsを渡しているので、ExampleGroupクラスのset_it_upメソッドを見てみます。

example_group.rb
      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
        )
        ・・・
	

https://github.com/rspec/rspec-core/blob/9de11e52d078696c01c3515351e1e634aada1115/lib/rspec/core/example_group.rb#L410

このset_it_upメソッドではargsMetadata.build_hash_fromメソッド、Metadata::ExampleGroupHash.createメソッドで使用されているので、それぞれ確認します。

example_group.rb
      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

https://github.com/rspec/rspec-core/blob/9de11e52d078696c01c3515351e1e634aada1115/lib/rspec/core/metadata.rb#L80

build_hash_fromメソッドでは、ユーザ定義のメタデータをargsから取り出します。
ここではargs['do_something']なので、hashには何もセットされないままとなります。
なお、このhashがどんな場合にセットされるのかは、本記事の最後にて記載します。

example_group.rb
      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

https://github.com/rspec/rspec-core/blob/9de11e52d078696c01c3515351e1e634aada1115/lib/rspec/core/metadata.rb#L248

createメソッド内にて、新しく作成したハッシュをgroup_metadataにセットし、次のnewメソッドにargsと一緒に渡しています。
なお、group_metadataのセット元であるhash_with_backwards_compatibility_default_procメソッドについては、ここでは詳しく述べませんが、新しくハッシュを作成する処理となっています。

ExampleGroupHashクラスではinitializeメソッドはありませんが、親のHashPopularにはあるので、そのメソッドを見てみます。

metadata.rb
      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

https://github.com/rspec/rspec-core/blob/9de11e52d078696c01c3515351e1e634aada1115/lib/rspec/core/metadata.rb#L120

ここでは、呼び出し元のgroup_metadataargsが、それぞれ@metadata@description_argsにセットされるのみとなります。

newメソッドは値のセットのみなので、次のpopulateメソッドを見てみます。

metadata.rb
        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

https://github.com/rspec/rspec-core/blob/9de11e52d078696c01c3515351e1e634aada1115/lib/rspec/core/metadata.rb#L128
ここでmetadata[:description_args]description_argsをセットしています。
そのため、ここで[do_something]metadata[:description_args]にセットされることになります。
なお、このmetadataは、specの結果表示などに使用されます。

これらのコードにより、describe構文を受け付けています。

1行で書けるようになったのはなぜか

metadata.rbでdescribe構文内での第2引数を受け付けているので、このファイルのログをたどり、どこでこのコードが入ったのかを確認します。

git logで確認したところ、populateメソッドの処理は、processメソッドから持ってきています。
https://github.com/rspec/rspec-core/commit/26dbd010965393a24c4051106faf71c8c3dfdb98#R351

その先のログをたどると、processメソッドでのdescribe構文内での第2引数の受付処理を、example_group.rbのset_it_upメソッドから移動しています。
https://github.com/rspec/rspec-core/commit/e9f9556188f866edbbd496ce6ba8d6aa491250d0

example_group.rbのログを見てみると、ファイルのリネーム元がbehaviour.rbとなっていました。
https://github.com/rspec/rspec-core/commit/6cbbaa7a4371d4ad46e0404aa11652acc884bc3a

behaviour.rbのログをたどっていくと、以下コミットでbehaviour.rbにdescribe構文内の引数2つが受け付けられるようになっていました。

behaviour.rb
      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

https://github.com/rspec/rspec-core/commit/afca72820329d5f84aa60fcecb7bf188d8394919

このコミットログを見ると、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の作者があります。
https://rspec.info/about/

そのため、今回の構文がMicronautをベースにしていることが分かります。

MicronautはRSpecとAPI互換を持つ軽量のBDDテストフレームワークですが、開発は2012年で止まっています。
https://github.com/spicycode/micronaut

RSpecに統合される前のコードは、Micronautプロジェクトのlib/micronaut/behaviour.rbとなります。
このログをたどると、以下コミットでbehaviour_group.rbからリネームされています。
https://github.com/spicycode/micronaut/commit/f2486d5392990e511c80887748dd0643e2289907

behavirour_group.rbのログを見ると、以下コミットでbehaviour_group_class_methods.rbから処理を移されています。
https://github.com/spicycode/micronaut/commit/5fded291fda25d5695a53acb7759400f45a875de

behaviour_group_class_methods.rbのログをたどると、以下コミットで、example_group.rbから処理を移しています。
https://github.com/spicycode/micronaut/commit/35afd959255d9f8e8303872d675b54143190edbf

このexample_group.rbの履歴を見ると、もともとはKernelモジュール.describeを呼び出す処理だったようです。なお、このコミットでdescribeメソッドの引数にdescが追加されています。
https://github.com/spicycode/micronaut/commit/db650fc371ff4506ab612c720df81a318cbccfac

ここが追加された最初の箇所かと思われましたが、実はその1つ前のコミットで既にdescを受け付けるKernelモジュールのdescribeメソッドが定義してありました。
https://github.com/spicycode/micronaut/commit/438874d4cd52627f88419bd129314de6a33a4686

kernel.rbを追いかけた結果、初期コミットでこの処理が追加されています。
https://github.com/spicycode/micronaut/commit/b13914369fecf7747cb6d46340ee0b71a1ee8010

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文字)
https://github.com/rubocop/rubocop/commit/9d35cfc55b2511031a31e9ba693a86f0898faa76

この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

※以下記事からコードを抜粋しました。
https://relishapp.com/rspec/rspec-core/docs/metadata/user-defined-metadata

この:fast:simple:bug => 73は、そのテストケースのみ使用可能なユーザ定義のメタデータとなります。
テストケースにおいて、ユーザ定義のメタデータ(example.metadata)を使用することで、:bug73:fast:simpleはtrueとなります。

おわりに

Pull Requestでコメントされていた内容をもとに、describeを使った書き方を調査し、個人での結論を出しました。
今回得た推測の結論とは別に、そこに至るまでの調査で、RSpecで使用できる構文の歴史や別のプロジェクトがRSpecに統合された話などを新たに知識として得ることができました。
この記事が誰かのお役に立てれば幸いです。

Discussion