【Rails】【RSpec】【shoulda-matchers】validate_uniqueness_ofのscoped_toではまったときのまとめ

目的

shoulda-matchersでモデルのバリデーションのテストを書いていたときに、Railsアトリビュートメソッドのキャッシュの動きで若干はまったため、簡単にまとめる。

github.com

shoulda-matchersの簡単な導入

shoulda-matchersはgemで、RSpecやMinitestにvalidate_presence_ofなどのワンライナーのテストのシンタックスを追加するもの。
詳しい話、やその他のテストメソッドに関しては、本家のreadme参照。(知らない機能がたくさんある!)

Railsアトリビュートメソッドのキャッシュ機能

メタプログラミングRubyを読んでいただいた方はわかるかもしれないが、Rails4系・5系のコードはメソッドの定義をキャッシュすることでパフォーマンスの向上を図っている。
詳しい実装には触れないが、具体的には以下のような動作になる(Rails 5.0.0.1, Ruby 2.3.1)

2.3.1 (main):0 > User.method_defined?(:name)
=> false
2.3.1 (main):0 > User.new
=> #<User id: nil, name: nil, role_id: nil, created_at: nil, updated_at: nil, email: "">
2.3.1 (main):0 > User.method_defined?(:name)
=> true

shoulda-matchersのvalidate_uniqueness_ofメソッドのscoped_toチェーン

モデルでのバリデーションを以下のように書く

class Phone < ActiveRecord::Base
  validates :phone, uniqueness: true
end

shoulda-matchersでテストを書くと以下のように書ける

require 'rails_helper'

describe Phone do
  it { is_expected.to validate_uniqueness_of(:phone) }
end

さらに、複数カラムでユニークになるようにバリデーションをすることもでき

class Phone < ActiveRecord::Base
  validates :phone, uniqueness: { scope: :contact_id }
end

shoulda-matchersで書くとこうなる

require 'rails_helper'

describe Phone do
  it { is_expected.to validate_uniqueness_of(:phone).scoped_to(:contact_id) }
end

しかし、上記のテストは(他のテストにもよるが、)コケる。

なぜテストが失敗するのか

以下のようなメッセージが出る

:contact_id does not seem to be an attribute on Phone.

ん?カラムあるよ? これでしばらく進まなかった。 実際は、scoped_toで指定されたカラムは、以下のようにshoulda-matchersのなかでmethod_defined?を使ってチェックしている。

def attribute_present_on_model?
  model.method_defined?("#{attribute}=") ||
    model.columns_hash.key?(attribute.to_s)
end

試しにbinding.pryでとめ、contact_id=を実行してみてからテストを流すとうまくいった。 メソッドのキャッシュのタイミングはバージョンによって異なるので、最新版であれば大丈夫かもしれません。

まとめ

感想

  • gemのソースを読むのはなかなか楽しい。
  • 今回のがバグなのかわからないが、最新版を使ってバグを踏んで、PR送ろう!