LoginSignup
11
12

More than 5 years have passed since last update.

RSpecのletって何やってるの

Posted at

遅延評価だのメモ化だのの説明はたくさん出てくるんですが、なんだかあんまり実感を持てなかったので、もうちょっと深くまで覗いてみました。

三行

  • (コンテキストのイメージ的には)describeはクラス、itはそのインスタンス(のコンテキスト)
  • letは定義されたスコープのクラス(describe)に対して変数名のdefine_methodをしている(正確ではない)
  • 参照時は初回はletに渡したブロック(から作られたメソッド)を評価した値をitごとに持つインスタンス変数のハッシュに登録、二回目以降はそのハッシュからkeyでfetchする、という動作

解説

RSpecの実装については大変参考になるエントリがあるので、letあたりのところまではここを参照していただければと思います。関連するところだけ簡単に説明すると、各describeExampleGroupというクラスの子クラスとして生成され、入れ子になってる際はそのまた子クラス……という風になっている、そしてitは各スコープのクラスのインタンス(コンテキスト)で実行される、ということを認識していれば良いと思います。例えば、

describe "Hoge" do
  it do
  end
  context "Fuga"
    it do
    end
  end 
end

にようなコードからは、RSpec::ExampleGroup::HogeRSpec::ExampleGroup::Hoge::Fugaというクラスが生成され、各itはそのスコープのクラスのインスタンスコンテキストになってる、ということです。

さて、上記のエントリでは軽くしか触れられていないMemoizedHelpersモジュールについて、もう少し詳しくみてみようと思います。
rspec/core/example_group.rbの

def self.subclass(parent, description, args, &example_group_block)
  subclass = Class.new(parent)
  subclass.set_it_up(description, *args, &example_group_block)
  ExampleGroups.assign_const(subclass)
  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

で触れられているやつですね。これについては最後の方に出てきます。

さて、実はMemoizedHelpersモジュール自体はすでにincludeextendされてます。
rspec/core/example_group.rb

  include MemoizedHelpers
  extend MemoizedHelpers::ClassMethods

このモジュールがletを解釈します。コメントを抜いて実装だけ取り出してみると、

        def let(name, &block)
          raise "#let or #subject called without a block" if block.nil?
          MemoizedHelpers.module_for(self).__send__(:define_method, name, &block)
          if block.arity == 1
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(RSpec.current_example, &nil) } }
          else
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(&nil) } }
          end
        end

となっています。

ブロックがnilだった場合の例外はわかるとして、

MemoizedHelpers.module_for(self).__send__(:define_method, name, &block)

部分と

          if block.arity == 1
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(RSpec.current_example, &nil) } }
          else
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(&nil) } }
          end
        end

部分にわけて考えます。

module_forはこうなっています。

(@rspec/core/memoized_helpers.rb)
def self.module_for(example_group)
  get_constant_or_yield(example_group, :LetDefinitions) do
    mod = Module.new do
      include Module.new {
        example_group.const_set(:NamedSubjectPreventSuper, self)
      }
    end
    example_group.const_set(:LetDefinitions, mod)
    mod
  end
end

constant_or_yieldLetDefinitions定数が定義されていない場合には以下のブロックを実行する(定義されている時はその定数の値を返す)、というメソッドで、初回(つまり、そのブロックで初めてletが読み込まれるタイミング)では続くblockが実行されます。NamedSubjectPreventSuper定数はsubjectに関係する要素なので、今は置いておきましょう。大事なのは、

example_group.const_set(:LetDefinitions, mod)
mod

の部分で、要するにexample_group、つまりdescribeで形容されるクラスの:LetDefinitions定数になにやらモジュールがひも付けられた、そしてこのメソッドはそのひも付けられたmoduleを返している、ということが重要です。そして呼び出した場所に戻ると、

MemoizedHelpers.module_for(self).__send__(:define_method, name, &block)

という風に呼び出しているんでしたね。name, &blockにはletに渡した二つの引数、すなわち名前とブロックが入っています。つまり、LetDefiniaiton定数に紐付けられたモジュールに対して、ここで(letに渡された)ブロックがメソッドとしてdefineされているんですね。しかしまだこのモジュールはincludeされたわけではないのでこの時点ではまだここで生やされたメソッドたちは利用できません。

そのincludeをしているのが最初に貼ったExampleGroupsubclassメソッドで呼び出している、MemoizedHelpers.define_helpers_on(subclass)部分です。

再掲(コメント省略)

      def self.subclass(parent, description, args, &example_group_block)
        subclass = Class.new(parent)
        subclass.set_it_up(description, *args, &example_group_block)
        ExampleGroups.assign_const(subclass)
        subclass.module_exec(&example_group_block) if example_group_block
        MemoizedHelpers.define_helpers_on(subclass) #ここ
        subclass
      end

helpers_onメソッドのところを見てみると、

      def self.define_helpers_on(example_group)
        example_group.__send__(:include, module_for(example_group))
      end

となっています。module_forメソッドは先程出てきましたね。今呼ぶとすでにメソッドが生やされているLetDefinitions定数に紐付けられたモジュールが返ってきます。これで、letに渡されたブロックがExampleGroupの子クラスにincludeされました。

さて、それではさっき二つに分けた部分の後半です。

#(@rspec/core/memoized_helpers.rb letメソッド)
          if block.arity == 1
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(RSpec.current_example, &nil) } }
          else
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(&nil) } }
          end
        end

ここの部分はメモ化を担当しています。つまり、この部分をコメントアウトしてもspecは普通に動作します。ただし、同じ変数を利用しているはずでも毎回ブロックが表されてしまうので、例えば、

  let(:user) { FactoryGirl.create(:user) }
  it {expect(user).to eq user}

は落ちます。expect(user)の中のuserと eqの後のuserは違うインスタンスを指してしまうからです(FactoryGirl.createがそれぞれに対して実行されてしまうため)。
親切にコメントが書いてあって、

          # Apply the memoization. The method has been defined in an ancestor
          # module so we can use `super` here to get the value.

とあります。
__memoizedというのはプライベートインスタンス変数@__memoizedのgetterで、ここに一度呼ばれた値は登録、呼ばれてなければdefine_methodされた実装を探して先祖を遡っていくわけです。ここが呼ばれる段階で(つまりfetch(name){}ブロックの中で)self.classancestorsしてみると、

=> [RSpec::ExampleGroups::Hoge::Fuga,
 RSpec::ExampleGroups::Hoge::Fuga::LetDefinitions,
 RSpec::ExampleGroups::Hoge::Fuga::NamedSubjectPreventSuper,
 RSpec::ExampleGroups::Hoge,
 RSpec::ExampleGroups::Hoge::LetDefinitions,
 ……

という風になっています。 Fugaレベルのスコープのlet宣言であれば、RSpec::ExampleGroups::Hoge::Fuga::LetDefinitionsに、Hogeレベルのスコープのlet宣言であればRSpec::ExampleGroups::Hoge::LetDefinitionsからdefine_methodされてるメソッドが呼ばれるようになってるわけですね。

describeスコープで定義された(正しくは宣言された)let変数がitごとに再評価される、というのも(書き方の)直感には反するように思っていましたが、実装を読むことで納得できました。ざっくばらんに言えば、example_groupインスタンスであるitコンテキストにそれぞれにインスタンス変数としてメモ化用テーブルハッシュが定義されている、という実装になってるからだったんですね。

itを分割することに対するオーバーヘッドと、テスト結果のわかりやすさのトレードオフについては諸説ありますが、例えば巨大なobjectは素直にインスタンス変数として定義してしまうなどすると、それは各子itについて継承されるので、itの数を変えないままcreate実行数は一度に抑えられます。ただDatabaseCleaner等のDB一掃gemを仕様していると、対象のobjectは良いのですがその関連先(belongs_toやhas_many先のDB recordですね)が消えてしまうので、あまり現実的な案ではないかもしれません。Cleanするタイミングをafter(:each)からafter(:context)などに変えるなど考えられはしますが、あまり良いプラクティスとは言えなさそうです……難しいですね。

11
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
12