以前
こんな記事書いた。
RSpecカスタムマッチャ作った話。
- RailsのValidationをテストするのに、実際にattributeに入れる値に紐付けて結果を確認したい
- この値だったらvalid、この値だったらinvalidといった具合
- で、それを簡単にできるカスタムマッチャ作ったよ、というのが前回
-
is { is_expected.to be_invalid_on(:name).with(nil) }
って感じで書ける
-
その後
- 結構社内で使われるようになった
- 一部独自拡張されながら、コピーが量産されてしまった
嬉しいけど、このままは良くない。
というわけで
Gemにしたよ!
使い方はREADME読んでくれー
Gemfileに書くだけで使えるはず
めんどかったこと
テスト。
RSpecカスタムマッチャでfailするケースを普通に書いちゃったら、テストfailするやんw、と。
というわけで、2種類のテストに分けた。
examples spec
こっちは、ライブラリユーザと同じように、このカスタムマッチャを用いてModelのValidationのテストを書くパターン。これはさっき言ったように、failするパターンは書けない。なので、 to
と not_to
を使うだけでOKとする。
context 'when target attribute has valid value' do
it { is_expected.to be_valid_on(:attr).with('valid value') }
it { is_expected.not_to be_invalid_on(:attr).with('valid value') }
end
unit test
しかし、 to
と not_to
を使い分けるだけだと、結局テストがfailしないので、failしたときだけ通る failure_message
ブロックをテストできない。なので、ここは単体テストでカバーする。
という方針自体は良いのだが、問題は、RSpecのカスタムマッチャはDSLを使って定義するってとこ。クラスやメソッドの定義じゃない。こんな感じ。
RSpec::Matchers.define :be_valid_on do |attr_name|
match do |model|
.....
end
chain :with do |value|
.....
end
failure_message do |model|
.....
end
end
なので、 match
chain
failure_message
に渡しているブロック自体を取り出さないと、単体テストできない。
しょーがないので、 RSpec::Matchers
のところをモックで置き換えた。
module ValidationExamplesMatcher
def self.register(matchers = RSpec::Matchers)
matchers.define do
......
end
end
register
end
カスタムマッチャ定義は、 RSpec::Matchers
を引数で受け取るクラスメソッドにくるんでおいてから呼び出す形に変更。
で、以下の様なモックを作成。
class MatchersMock < BasicObject
attr_reader :defined_matchers, :match_blocks, :chain_blocks, :failure_message_blocks
attr_reader :value, :context
def define(name, &block)
@defined_matchers ||= []
@defined_matchers << name
@current = name
instance_exec(:attr, &block)
end
def match(&block)
@match_blocks ||= {}
@match_blocks[@current] = block
end
def chain(name, &block)
@chain_blocks ||= {}
@chain_blocks[@current] ||= {}
@chain_blocks[@current][name] = block
end
def failure_message(&block)
@failure_message_blocks ||= {}
@failure_message_blocks[@current] = block
end
end
モックの中身でやってることは主に3つ。
-
define
がコールされたら、引数のブロックをinstance_exec
で実行- すると、
match
などの他のメソッドがこのモックオブジェクトのスコープでコールされる
- すると、
-
match
などのdefine
意外のメソッドでは、渡されたブロックを保管しておく - テストで検証対象になる値にreaderを作っておく
最後に、このモックを引数にして、マッチャ定義のところで作っておいたメソッドを呼び出す。
RSpec.describe 'be_invalid_on matcher' do
let(:matchers) { MatchersMock.new }
before { ValidationExamplesMatcher.register(matchers) }
end
ここまでやると、モックオブジェクトに各ブロックが保管されている状態になるので、ブロックを取り出して実行し、結果を検証することができる。ブロックの実行は、再度モックオブジェクトを使って instance_exec
する。
describe 'with on_context block' do
it 'sets @context' do
matchers.instance_exec(:a_context, &on_context_chain_block)
expect(matchers.context).to eq :a_context
end
end
さいごに
とこんな感じでGemを出すにあたり十分なテストは用意したつもりですが、もしもっと良いやり方があったら教えてくださいmm