削除可能制約、削除不可制約 shared_example: 小林研 Rails Tips (5)

はじめに

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

削除可能制約・削除不可制約

データが削除可能か不可能かをチェックする shared_example です。削除可能は制約なのかという話もありますが、こちらだけ制約という言葉をつけないのもバランスが悪いので、削除可能制約としています。

基本的に何も条件がなければデータは削除可能なので、簡単に通ります。面倒なのは削除不可制約の方です。どんな場合でも削除が不可能にするのか、リレーションが設定されている時だけ削除不可なのかによってテストのやり方が変わってきます。

先日紹介した年度を示す Year は次の年度を作成することはありますが、削除することはありません。このため、このモデルは常に削除不可になります。

require "rails_helper"

RSpec.describe Year, type: :model do
  let(:year) { years :y2023 }

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

    it_behaves_like "削除不可制約"
  end
end

一方、クラスを示す Kurasu は作ったばかりは削除できますが、学生が一人でも登録されると削除できなくなります。この場合、削除できる fixture と削除できない fixture を用意してどちらもテストをする必要があります。この Kurasu のテストの該当部分だけを抜粋すると以下のようになります。

require "rails_helper"

RSpec.describe Kurasu, type: :model do
  let(:kurasu) { kurasu :k43 }

  context "属性に関する共通テスト" do
    context "without gnumbers" do
      subject { kurasus :can_delete }
      it_behaves_like "削除可能制約"
    end

    context "with gnumbers" do
      subject { kurasu }
      it_behaves_like "削除不可制約"
    end
  end

削除可能制約および削除不可制約の shared_example は非常に簡単で、subject.destroy の実行の前後でオブジェクトのカウントが変化するかどうかを確認するだけです。これを実現するための「削除可能制約」「削除不可制約」の shared example は以下のようになります。

shared_examples_for "削除可能制約" do
  it "削除できること" do
    klass = subject.class
    expect { subject.destroy }.to change(klass, :count).by(-1)
  end
end

shared_examples_for "削除不可制約" do
  it "削除できないこと" do
    klass = subject.class
    expect { subject.destroy }.not_to change(klass, :count)
  end
end

この example では、通常の Proc の前後でクラスのオブジェクトのカウントを取得しています。削除可能制約ではカウントが1減るのに対し、削除不可制約ではカウントが減らないことを確認しています。

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

常に削除できないモデルの場合には、 before_destroy でエラーを追加するとともに、例外を発生させればよいです。Year モデルでは以下のように on_destroy_validation_method を追加しています。このメソッドの中では、errors.add でエラーメッセージを追加し、:abortthrow します。

  # @note 年度は削除しない
  before_destroy :on_destroy_validation_method
  def on_destroy_validation_method
    errors.add :base, "年度は削除できません"
    throw :abort
  end
  private :on_destroy_validation_method

一方、kurasu は依存関係の削除制約で削除不可かどうかを制御します。dependentrestrict_with_error となっているため、gnumbers が設定されている親は削除できないことになります。逆に学生が登録されていないクラスは、エラーが発生しないため削除できることになります。

  # @return [ActiveRecord::Relation<Gnumber>]  対応する学生番号一覧
  has_many :gnumbers, dependent: :restrict_with_error

おわりに

依存関係で削除不可にすることは多いので、よく削除可能制約の方が失敗することが多いです。このため、私の場合にはどのモデルにも can_delete というこの制約のテストのためだけの fixture を用意しています。この時、can_delete に間違えてリレーションを設定してしまうと、削除不可になってしまうので注意が必要です。