以前
こんな記事書いた。
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