Open18

rspecを読んでみる

inomotoinomoto

業務で使っているが、ちょっと変わったことをしようとするたびに挙動が謎で都度ハマっておりつらい。
そこでちょっとコードを読んでみることで裏で起こっていることを知ってみようという試み。

対象はこれ
https://github.com/rspec/rspec-core

なお読んでいるコードは現時点の最新バージョンであるv3.10.1

inomotoinomoto

READMEをざっと読むとごく基本的な使い方などが書いてある。が、少々気になる記述を発見。
https://github.com/rspec/rspec-core#a-word-on-scope

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はインスタンスメソッドのようなものとのこと。

具体的にどういう処理が動いているのかは後に追うとして、このだけでも諸々のスコープの挙動が少し納得できた。

inomotoinomoto

たまに書き下すと無駄に長くなる処理を即時ヘルパー関数に切り出す事があるが、上記を踏まえるとこんな感じで呼べる:

  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 instancetest class!1がPASS、test class!0test class!2がFAILになる。

とはいえ突然def self.xxxが登場するとパッと見で何だそれって感じなので自重すべきか。やるならせめて

    classm = -> { "class!" }
    list = 3.times.map { |i| classm.call + i.to_s }.each do |str|

のが多少マシか。多少。

inomotoinomoto

さてコードを読む。
本件は特に〇〇の挙動を知りたいとか全体感を把握してパッチしたいとか目的があるわけではないので、とりあえずエントリポイントに突撃。

リポジトリルートを見ると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.configurationRSpec.worldがここでの主な登場人物なもよう。
lib/rspec/core/configuration.rblib/rspec/core/world.rbもあるのでここらを見ていけばよかろう。

ところで上記のコード、コメントは略したがメソッド定義の並び順はオリジナルのまま。上から順に見ていけばほぼ呼び出し順に並んでいる。超読みやすい。参考にしよう。

inomotoinomoto

先に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。
recordtraverse_example_group_trees_untilはちと気になるが、利用先を見てから戻ってくるくらいでよさそう。

inomotoinomoto

次に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あたりを追ってみる。

inomotoinomoto

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にありそうなのでそっちを見る。

inomotoinomoto

てか、既存のクラスインスタンスにメソッドを追加する方法すごいな。
これRSpec.define_method(name) do ...するのと何か違うんだろうか。ruby力が足りずちょっとわからない。そのうち調べよう。

inomotoinomoto

気を取り直してexample_group.rbを見ると、220行目あたりに

      #     RSpec.describe "something" do # << This describe method is defined in
      #                                   # << RSpec::Core::DSL, included in the
      #                                   # << global namespace (optional)

えー。

inomotoinomoto

もう少し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を追う。

inomotoinomoto
      # 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

うーん、まぁ要するにメソッド定義してるだけっぽい。

inomotoinomoto

まとまると、example_group.rbRSpec::Core::ExampleGroupのクラス定義内で

  1. define_example_group_method :example_groupを呼ぶ
  2. idempotently_define_singleton_method:example_groupとブロックが渡される
  3. define_methodされる
  4. RSpec::Core::ExampleGroupexample_groupメソッドが生え、定義は2. で渡したブロックとなる

という感じか。describeやcontextも同様にdefine_example_group_methodされているので、今欲しいコードはdefine_example_group_method定義内にあるブロックだと言える。

inomotoinomoto

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)を定義する」と見える。

inomotoinomoto

registration_collectionの式を見るとなんやかんやでRSpec.world.example_groupsもしくはchildrenが入る。
前者はおそらく見たまんまで、実行するexample_groupたちが収められているのだろう。であればchildrenはdescribeを呼び出したときの親に相当するgroupの配下にいるgroupsだろうと推測できる。

subclassを読んでいるブロックはほとんどパラメータをゴニョゴニョしているだけなので、そこはあまり気にせずにsubclassメソッドの方を見ていく。

inomotoinomoto
      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の定義は追っていないが

inomotoinomoto

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にもろもろ反映させたりしている。

inomotoinomoto

ここまでで、ざっくりRSpec.describeで何が起こっているのかはわかった気がする。

RSpec::Support.thread_local_dataMetadata::ExampleGroupHash、configurationから呼ぶapply_derived_metadata_toconfigure_groupなど、深堀りすると良さそうな要素がいくつか残っているが、ひとまずここまで。

inomotoinomoto

ざっくり、rspecを実行するとどんなことが起こるのかや、普段書いているRSpec.describeとExampleGroupの関係などの全体構成感がわかった。

次はitやlet、before/afterなどを追っていくことになりそう。