LoginSignup
10
9

More than 5 years have passed since last update.

ValidationのテストをRSpecカスタムマッチャにしたやつをGemにしたよ

Posted at

以前

こんな記事書いた。
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するパターンは書けない。なので、 tonot_to を使うだけでOKとする。

spec/examples/validations_spec.rb
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

しかし、 tonot_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 のところをモックで置き換えた。

lib/validation_examples_matcher.rb
module ValidationExamplesMatcher
  def self.register(matchers = RSpec::Matchers)
    matchers.define do
      ......
    end
  end

  register
end

カスタムマッチャ定義は、 RSpec::Matchers を引数で受け取るクラスメソッドにくるんでおいてから呼び出す形に変更。

で、以下の様なモックを作成。

spec/spec_helper.rb
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を作っておく

最後に、このモックを引数にして、マッチャ定義のところで作っておいたメソッドを呼び出す。

spec/unit_tests/be_invalid_on_matcher_spec.rb
RSpec.describe 'be_invalid_on matcher' do
  let(:matchers) { MatchersMock.new }
  before { ValidationExamplesMatcher.register(matchers) }
end

ここまでやると、モックオブジェクトに各ブロックが保管されている状態になるので、ブロックを取り出して実行し、結果を検証することができる。ブロックの実行は、再度モックオブジェクトを使って instance_exec する。

spec/unit_tests/be_invalid_on_matcher_spec.rb
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

10
9
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
9