遅延評価とRSpecとActive Record
仕事をしていて面白いコードを見たので紹介する。
次のコードについて話す。適当に書いているし、適宜コードも書き換えているので、間違っていたら適当に補完して読んで欲しい。
# app/models/user.rb class User < ApplicationRecord scope :admin, -> { where(role: :admin) } end
# spec/models/user_spec.rb RSpec.describe User do describe '.admin' do let(:user1) { FactoryBot.create :user, :as_admin } let(:user2) { FactoryBot.create :user } let(:user3) { FactoryBot.create :user } before do user2.update!(role: :admin) user3.update!(role: :admin) end it do expect(User.admin).to contain_exactly(user1, user2, user3) end end end
うごきそう
なにも考えずに見るとこのテストは動きそうに見える。user1は最初からadminで、user2とuser3はbeforeの中でadminに変更されている。
つまりUser.adminの結果はuser1, user2, user3の3つのはずである。
確かにテストを実行すると成功する。
うごかなさそう
ところが、私は最初「なんでこのテストが通るのか」がさっぱり分からなかった。絶対に失敗すると思った。ところがテストは通っている。
RSpecのletは遅延評価である。つまりletで定義した名前が実際に参照されるまで、letに渡したブロックは実行されない。
なにが言いたいかというと、User.adminを実行するタイミングではまだuser1は参照されていないので、user1に対応するレコードは生成されていない。1
つまりUser.adminはuser2とuser3のみを返すはずである。
次のようなコードを書くことで評価順を試すことができる。
def expect(_) Object.new.tap do |o| def o.to(_) end end end def contain_exactly(*_) end # 実行すると、1, 2の順で表示されるので、テストでは`User.admin`が`user1`よりも先に評価されていることがはっきりする。 expect(p 1).to contain_exactly(p 2)
実際にitのブロックの先頭でbinding.pryを仕込んでUser.adminの返す値を見てみると、user2とuser3のみが含まれている。
it do # ここにbinding.pryを挿入。 # User.admin.count をすると、2が返る。 binding.pry expect(User.admin).to contain_exactly(user1, user2, user3) end
しかし、expect(User.admin).to contain_exactly(user1, user2, user3)は成功する。なんだこれ。
でもうごく
ネタばれがあるのでこの問題の答えを自分で考えたい人はちょっと立ち止まってみよう
以下ネタばれ↓↓↓2
User.adminが返すのはActiveRecord::Relationである。そして、ActiveRecord::Relationは評価されただけではSQLを発行しない。
そのため、user1が評価された後にSQLが発行されるようになっている。
次のような実行順序でコードが評価される。
expectメソッドの引数のUser.adminの評価expectメソッドの評価toメソッドの引数のcontain_exactlyメソッドの引数のuser1,user2,user3の評価contain_exactlyメソッドの評価toメソッドの評価- 多分ここでSQLが発行される
結論
難しいからlet!を使うとかbeforeでuser1を呼び出したりとかした方が良さそう。