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