関連確認 shared_example: 小林研 Rails Tips (6)

はじめに

Rails Tips の 6 回目です。連日の 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

関連確認

ここまでは validates の確認でしたが、ここからは関連のチェックになります。最初は belongs_to 側の確認になります。例えば学生番号 (Gnubmer) は、学生(Gakusei)、クラス(Kurasu)、年度(Year) に belongs_to で繋がっています。これらは反対側からは has_many の関連になります。テストの関連確認の部分だけを抜き出すとこのようになります。

require "rails_helper"

RSpec.describe Gnumber, type: :model do

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

    it_behaves_like "関連確認", :gnumber, has_many: %i[gakusei kurasu year]
  end
end

一方、学生住所 (AddressTel) も、学生(Gakusei) に belong_to の関係がありますが、こちらは Gakusei からは has_one の関係にあります。

require "rails_helper"

RSpec.describe AddressTel, type: :model do

  context "属性に関する共通テスト" do
    subject { address_tels :can_delete }

    it_behaves_like "関連確認", :address_tel, has_one: %i[gakusei]
  end
end

簡単な関連であれば、has_manyhas_one だけでことが足りるのですが、同じモデルに異なる名前でリンクが貼られる場合、参照や逆参照の名前がモデル名からは自動的に作成できません。例えば、推薦受検(Suisen)では、希望キャンパス(campus)と合格キャンパス(goukaku_campus)の二つのリレーションが存在します。前者は Rails の規約通りですが、後者は記述もテストも面倒になります。この場合、逆関連のメソッド名を children (has_many の場合) または child (has_one の場合) を指定する必要があります。

require "rails_helper"

RSpec.describe Suisen, type: :model do

  context "属性に関する共通テスト" do
    subject { suisens :can_delete }

    it_behaves_like "関連確認", :suisen, has_many: %i[campus year], has_one: %i[jukensei]
    it_behaves_like "関連確認", :suisen, has_many: %i[goukaku_campus], children: :goukaku_suisens

関連は fixtures を読み込んだ段階で設定がされてしまっているので、関連確認の shared_example はそのオブジェクトを削除したときに相手側の非常に簡単で、subject.destroy の実行の前後でオブジェクトのカウントが変化するかどうかを確認するだけです。これを実現するための「削除可能制約」「削除不可制約」の shared example は以下のようになります。children, child が shared_example に渡されない場合(ほとんどの場合は渡されません)には、モデル名(model)から自動生成します。

shared_examples_for "関連確認" do |model, hash|
  has_many_relations = hash[:has_many]
  has_one_relations = hash[:has_one]
  children = hash[:children] || model.to_s.pluralize
  child = hash[:child] || model
  context "#{model}を削除するとき" do
    has_many_relations&.each do |relation|
      it "#{relation}.#{children}.count が1つ減ること" do
        parent = subject.send(relation)
        expect { subject.destroy }.to change(parent.send(children), :count).by(-1)
      end
    end
    has_one_relations&.each do |relation|
      it "#{relation}.#{child} が nil になること" do
        parent = subject.send(relation)
        subject.destroy
        expect(parent.send("reload_#{child}")).to be_nil
      end
    end
  end
end

この shared_example を実行するためには、subject が削除できる必要があります。このため、上のすべての subject が can_delete になっているわけです。

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

関連はモデルに belongs_to を記述するだけです。

  # @return [Kurasu] 対応するクラス
  belongs_to :kurasu
  # @return [Gakusei] 対応する学生
  belongs_to :gakusei
  # @return [Year] 対応する年度
  belongs_to :year

推薦の場合の合格キャンパスは、モデルの名前と関連名が異なるので、class_name でモデル名を指定しています。また、合格しない受検生もいるため、この属性は nil になる可能性があります。そのため、optional: true が必要となっています。逆に通常のリレーションに使う id については、presence: true の確認はする必要がなくなりました。

  # @return [Campus] 対応するキャンパス
  belongs_to :campus
  # @return [Campus] 対応する合格キャンパス
  belongs_to :goukaku_campus, class_name: "Campus", optional: true

おわりに

関連確認は意外とミスが起こりやすいので、こういう簡単なテストで確認しておくことは重要だと思います。