複合一意制約 shared_example: 小林研 Rails Tips (4)

はじめに

Rails Tips の 4 回目です。連日の model のテストを簡単に書くための shared_example の紹介の続きです。しばらくは私の Shared example の雛形 (lib/templates/rspec/model/model_spec.rb)を一つずつ解説していく形です。

require "rails_helper"

# RSpec.describe <%= class_name %>, type: :model do
#  let(:<%= singular_table_name %>) { <%= plural_table_name %> :<%= singular_table_name %> }
#  let(:can_delete) { <%= plural_table_name %> :can_delete }

#  context "属性に関する共通テスト" do
#    subject { can_delete }
#    let(:another_object) { <%= plural_table_name %> :another_object }

#    it_behaves_like "存在制約", %i[]
#    it_behaves_like "一意制約", %i[]
#    it_behaves_like "複合一意制約", %i[]
#    it_behaves_like "削除可能制約"
#    it_behaves_like "削除不可制約"
#    it_behaves_like "関連確認", :<%= singular_table_name %>, has_many: %i[], has_one: %i[], children: :optional, child: :optional
#    it_behaves_like "親削除時に自分も削除", :<%= singular_table_name %>, %i[has_many has_one]
#    it_behaves_like "親は削除不可", :<%= singular_table_name %>, %i[has_many has_one]
#    it_behaves_like "親削除時にnullを設定", :<%= singular_table_name %>, %i[has_many has_one]
#  end

複合一意制約

モデルの属性のうち、同じ値の組み合わせを許可しないもの(値の組み合わせが unique でなければならない)をチェックするための shared_example です。例えば、学生番号を示す Gnumber では、年度(year_id)とクラス(kurasu_id)と学生(gakusei_id) はユニークな組み合わせである必要があります。一意制約と同様オブジェクトが二つインスタンスがないとテストできないので、雛形にある another_object を用意する必要があります。

require "rails_helper"

RSpec.describe Gnumber, type: :model do
  let(:g4321) { gnumbers :g4321 }

  context "属性に関する共通テスト" do
    subject { gnumbers :can_delete }
    let(:another_object) { g4321 }

    it_behaves_like "複合一意制約", %i[year_id kurasu_id gakusei_id]
  end
end

これを実現するための「存在制約」 shared example は以下のようになります。

shared_examples_for "複合一意制約" do |keys|
  it "別のオブジェクトの #{keys.join ", "} の内容が全て等しいとき、エラーになること" do
    keys.each do |key|
      subject[key] = another_object[key]
    end
    expect(subject).not_to be_valid
  end

  keys.each do |except_key|
    it "少なくとも #{except_key} の内容が異なるとき、エラーにならないこと" do
      compare = true
      keys.each do |key|
        if key == except_key
          compare = false if subject[key] == another_object[key]
        else
          subject[key] = another_object[key]
        end
      end
      expect(subject).to be_valid if compare
    end
  end
end

この example では、最初に与えられたキーをすべて同じにしてエラーになることを確認し、その後少なくとも一つのキーが異なればエラーにならないことを確認しています。

正しく動作させるための実装

複合一意制約を検証するには以下のように記載します。一意制約と書き方は似ていますが、uniqueness キーは scope を持つハッシュになっています。これは、gakusei_id を設定するにあたり、year_id と kurasu_id が共通の場合に unique かどうかをテストするということです。最後の要素を設定するにあたり、それ以外のキーが条件になるという意味になります。

class Gnumber
  validates :gakusei_id, uniqueness: {scope: %i[year_id kurasu_id]}
end

おわりに

複合一意制約は真面目にテストを書こうとすると面倒です。こういうのはわかっていても、毎回書きたくはないので、shared_example にしておくのは楽だと思います。