30. モデルテストのためのメソッド群

1週間使って検索モジュールを設計・実装していた。かなり使い勝手がいいものができたので、近いうちに紹介する。

今日は溜まりまくったモデル系のお話。モデルのテストのうち、属性や関連に関するものはテストをするためではなく、実装抜けを防ぐためのものになる。これらはほぼ共通に記述できるし、こんなところに手間はかけられないので、ヘルパメソッドで手抜きをする。当初、モデルの特性に合わせて関連するヘルパメソッドを紹介する記事を書いていたが、わかりにくいので先にヘルパメソッドを紹介してしまう。

今回のメソッドは基本的に @target を対象とし、特別な場合にプロックでテストするものを変えられるようにしている。以下に個々のヘルパメソッドを紹介する。

属性の存在テスト

nil 不可の属性が nil の時にエラーになるかをチェックする。そもそも作成自体に失敗していたら、テストが通過してしまうので、問題ない場合に作成が成功するかどうかテストをしている。このため、検査する属性分+1 個のテストが実行される。nil 不可の属性がない場合にも、引数なしで呼び出すことで、モデルの作成テストをする。

先に書いたように @target もしくはブロックで作成されたオブジェクトに対して、対応する属性を nil にして validation チェックをしている。以前は歯抜けのハッシュを作ってから new していたが、属性の代入が Hash 形式で書けることを知り楽に書けるようになった。

# @praam [Array<Symbol>] 検査する presence 属性
# @param [Proc, nil] ターゲットを作成する Proc オブジェクト(必要な場合)
# @note Proc がない場合には @target を検査
def check_presence_validates(keys = [])
  it { expect(block_given? ? yield : @target).to be_valid }

  keys.each do |key|
    context "when #{key} is nil" do
      it do
        object = block_given? ? yield : @target
        object[key] = nil
        expect(object).to_not be_valid
      end
    end
  end
end

ユニーク性のテスト(単一属性)

単一属性の一意性の確認テストである。検査対象は @target のみで、もう一つのオブジェクトをブロックで渡す。ブロックで渡されたオブジェクトの該当する属性を @target と同じにすることで、一意性を破壊する。これも検査する属性分のテストを実行する。

# @praam [Array<Symbol>] 検査する unique 属性
# @param [Proc] もう一つのオブジェクトを作成する Proc オブジェクト
def check_unique_validates(keys)
  keys.each do |key|
    context "when another object has same #{key}" do
      it do
        object = yield
        object[key] = @target[key]
        expect(object).to_not be_valid
      end
    end
  end
end

ユニーク性のテスト(複数属性)

複数属性の一意性の確認テストである。これは複数の属性が同時に同じ値になった場合にエラーとなるかどうかを確認するものである。これも 検査対象が @target で、ブロックで渡されたオブジェクトの属性を変えながらテストをする。presence とは逆で、最初にすべての属性を一致したときにエラーになることを確認する。その後、一つずつ属性を修正しないことで一意性を確認する。ただし、@target とオブジェクトがそもそも同じ属性値であった場合には、テストをスキップする。このため、ブロックで渡すオブジェクトは検査対象の属性に対して、@target と異なる値になっていることが望ましい。

# @praam [Array<Symbol>] 検査する unique 属性
# @param [Proc] もう一つのオブジェクトを作成する Proc オブジェクト
def check_plural_unique_validates(keys)
  context "when another object has same #{keys.join ', '}" do
    it do
      object = yield
      keys.each do |key|
        object[key] = @target[key]
      end
      expect(object).to_not be_valid
    end
  end
  context "when another object has at least one different keys(#{keys.join ', '})" do
    it do
      keys.each do |except_key|
        object = yield
        compare = true
        keys.each do |key|
          if key == except_key
            compare = false if object[key] == @target[key]
          else
            object[key] = @target[key]
          end
        end
        expect(object).to be_valid if compare
        object.destroy
      end
    end
  end
end

削除不可のテスト

モデルによっては削除を禁止しているものがある。ここではそれをテストする。引数はない。

# @param [Proc, nil] ターゲットを作成する Proc オブジェクト(必要な場合)
# @note Proc がない場合には @target を検査
def check_reject_destroy_validates
  context "should not destroy" do
    it do
      object = block_given? ? yield : @target
      klass = object.class
      expect { object.destroy }.to change(klass, :count).by 0
      end
  end
end

belongs_to のテスト

関連のうち belongs_to のテストを行う。相手先が has_many なのか has_one なのかによって、見え方が異なるので、それぞれ異なる配列で渡す。このテストは、削除が可能なオブジェクトの場合のみに実施し、削除した場合に has_many の場合には関連数が減るかどうか、has_one の場合に関連が nil になるかどうかを確認する。このテストにより、beglons_to, has_many, has_one の書き忘れが防げる。

# @param [Array<Symbol>] has_many_relations has_many の相手先関連名
# @param [Array<Symbol>] has_one_relations has_one の相手先関連名
# @param [Proc, nil] ターゲットを作成する Proc オブジェクト(必要な場合)
# @note Proc がない場合には @target を検査
# @note FactoryGirl を削除したときに、関連からデータ参照が消えるかを確認
def check_belongs_to(model, has_many_relations, has_one_relations = [])
  context "when destroying #{model}" do
    has_many_relations.each do |relation|
      models = model.to_s.pluralize
      it "#{relation}.#{models}.count should decrease" do
        object = block_given? ? yield : @target
        parent = object.send(relation)
        expect { object.destroy }.to change(parent.send(models), :count).by(-1)
      end
    end
    has_one_relations.each do |relation|
      it "#{relation}.#{model} should be nil" do
        object = block_given? ? yield : @target
        parent = object.send(relation)
        object.destroy
        expect(parent.send(model, :true)).to be_nil
      end
    end
  end
end

dependent destroy のテスト

belongs_to で関連した場合、相手先のクラスが削除されたときにどうなるかをテストする必要がある。ここでは、依存によって消えるかどうかのテストをする。相手先の has_many か has_one が dependent: :destroy だった場合に、テストが通過する。これも書き忘れのテストのためである。

# @param [Class] 確認するモデルクラス
# @param [Array<Symbol>] relations has_many, has_one の相手先関連名
# @param [Proc, nil] ターゲットを作成する Proc オブジェクト(必要な場合)
# @note Proc がない場合には @target を検査
# @note 関連を削除したときに、FactoryGirl が消えるかを確認
def check_dependent_destroy(model, relations)
  relations.each do |relation|
    context "when destroying #{relation}" do
      it "should be destroyed by dependency" do
        object = block_given? ? yield : @target
        parent = object.send(relation)
        expect { parent.destroy }.to change(model.to_s.classify.constantize, :count).by(-1)
      end
    end
  end
end

dependent nullify のテスト

相手先の has_many か has_one が dependent: :nullify だった場合にテストが通過する。関連先が削除された場合に、belongs_to が nil になることを確認する。今のところ、このような関係がないため、正しく動作するかどうかはまだ確認できていない。

# @param [Array<Symbol>] relations has_many, has_one の相手先関連名
# @param [Proc, nil] ターゲットを作成する Proc オブジェクト(必要な場合)
# @note Proc がない場合には @target を検査
# @note FactoryGirl が存在するときに、関連が nil になることを確認
def check_destroy_nullify_for_relations(model, relations)
  relations.each do |relation|
    context "when destroying #{model}.#{relation}" do
      it "#{model} should reject destroying #{relation}" do
        object = block_given? ? yield : @target
        parent = object.send(relation)
        parent.destroy
        expect(object.send(relation)).to be_nil
      end
    end
  end
end

dependent restrict のテスト

相手先の has_many か has_one が dependent: :restricted_with_error だった場合にテストが通過する。関連がある場合には、関連先のモデルが削除できないことを確認する。基本的にはこのエラーが出ないように view で制限をするが、モデルでも制約をする。

# @param [Array<Symbol>] relations has_many, has_one の相手先関連名
# @param [Proc, nil] ターゲットを作成する Proc オブジェクト(必要な場合)
# @note Proc がない場合には @target を検査
# @note FactoryGirl が存在するときに、関連を削除できないことを確認
def check_reject_destroy_for_relations(model, relations)
  relations.each do |relation|
    context "when destroying #{model}.#{relation}" do
      it "#{model} should reject destroying #{relation}" do
        object = block_given? ? yield : @target
        parent = object.send(relation)
        parent.destroy
        expect(parent.errors[:base].size).to eq(1)
      end
    end
  end
end

主にクラスメソッドを一括テストするヘルパ

target とメソッド、引数、期待する答えの組を渡し、複数のテストを一括で実施する。

# @param [Object] target テストするオブジェクト
# @param [Array<Array<Object>>] answers method, answers, args
def target_method_send_test(target, *array)
  array.each_slice(3) do |method, args, answers|
    if args
      expect(target.send(method, *args)).to eq answers
    else
      expect(target.send(method)).to eq answers
    end
  end
end

主にインスタンスメソッドを一括テストするヘルパ

target_array とメソッド、引数、期待する答えの組を渡し、複数のテストを一括で実施する。

# @param [Array<Object>] target_array テストするオブジェクト配列
# @param [Array] array 引数配列 (メソッド名, 引数, その応答)の繰り返し
def target_array_method_send_test(target_array, *array)
  array.each_slice(3) do |method, args, answers|
    if args
      expect(target_array.map { |o| o.send(method, *args) }).to eq answers
    else
      expect(target_array.map(&method)).to eq answers
    end
  end
end

テンプレートの設定

これらのメソッドの記述抜けを避けるため、モデル作成時のテンプレートにこれらに記載しておく。lib/templates/rspec/model.rb に以下のように記載した。また、xxx_spec.rb の下部に spec/support/create_factries/*.rb に記述すべきものも書いてある。必要に応じて、これらを適切に記述する。

require 'rails_helper'

RSpec.describe <%= class_name %>, :type => :model do
  context "common validation check" do
    before do
      @target = <%= singular_table_name %>_factory :<%= singular_table_name %>
    end

#    check_presence_validates %i( )
#    check_unique_validates %i( ) do
#      <%= singular_table_name %>_factory :other
#    end
#    check_plural_unique_validates %i ( ) do
#      <%= singular_table_name %>_factory :other
#    end
#    check_reject_destroy_validates
#    check_belongs_to :<%= singular_table_name %>, %i( :has_many ), %i( :has_one )
#    check_dependent_destroy :<%= singular_table_name %>, %i( :has_many, :has_one )
#    check_reject_destroy_for_relations :<%= singular_table_name %>, %i( :has_many, :has_one )
  end

  context 'after all <%= plural_table_name %> are registrered' do
    before do
      @<%= plural_table_name %> = <%= singular_table_name %>_all_factories
    end

    context 'for <%= class_name %> class' do
      subject { <%= class_name %> }

#      it 'should receive METHOD1, METHOD2' do
#        target_method_send_test subject,
#          :METHOD1, :ARG1, :ANS1,
#          :METHOD2, :ARG2, :ANS2
#      end
    end

    context 'for <%= class_name %> instances' do
      subject { @<%= plural_table_name %> }

#      it 'should receive METHOD3, METHOD4' do
#        target_array_method_send_test subject,
#          :METHOD3?, :ARG3, :ANS3,
#          :METHOD4?, :ARG4, :ANS4
#      end
    end
  end
end

##### write to spec/support/create_factries/<%= singular_table_name %>_factory.rb
<%= class_name %>FactoryHash = {
  one: %w( ),
  two: %w( ),
  three: %w( )
}

# @param [Symbol, String] key オブジェクトを一意に決定するキー
# @return [<%= class_name %>] <%= class_name %> FactoryGirl オブジェクト
def <%= singular_table_name %>_factory(key)
  n, c = <%= class_name %>FactoryHash[key.to_sym]
  FG.find_or_create(
    :<%= singular_table_name %>,
    name: n,
    code: c,
    sort_order: <%= class_name %>.sort_orders[key.to_sym]) if n
end

# @return [Array<<%= class_name %>>] <%= class_name %> FactoryGirl オブジェクトの配列
def <%= singular_table_name %>_all_factories
  <%= class_name %>FactoryHash.keys.map { |k| <%= singular_table_name %>_factory(k) }
end

長くなったので今日はここまで。

written by iHatenaSync