pockestrap

Programmer's memo

遅延評価と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で、user2user3beforeの中でadminに変更されている。 つまりUser.adminの結果はuser1, user2, user3の3つのはずである。 確かにテストを実行すると成功する。

うごかなさそう

ところが、私は最初「なんでこのテストが通るのか」がさっぱり分からなかった。絶対に失敗すると思った。ところがテストは通っている。

RSpecletは遅延評価である。つまりletで定義した名前が実際に参照されるまで、letに渡したブロックは実行されない。

なにが言いたいかというと、User.adminを実行するタイミングではまだuser1は参照されていないので、user1に対応するレコードは生成されていない。1 つまりUser.adminuser2user3のみを返すはずである。

次のようなコードを書くことで評価順を試すことができる。

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の返す値を見てみると、user2user3のみが含まれている。

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が発行されるようになっている。

次のような実行順序でコードが評価される。

  1. expectメソッドの引数のUser.adminの評価
  2. expectメソッドの評価
  3. toメソッドの引数のcontain_exactlyメソッドの引数のuser1, user2, user3の評価
  4. contain_exactlyメソッドの評価
  5. toメソッドの評価
    • 多分ここでSQLが発行される

結論

難しいからlet!を使うとかbeforeuser1を呼び出したりとかした方が良さそう。


  1. user2, user3beforeで参照されているので生成されている。

  2. 反転しても出てこないよ、ゴメンね