はじめに
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_many
と has_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
おわりに
関連確認は意外とミスが起こりやすいので、こういう簡単なテストで確認しておくことは重要だと思います。