ようへいの日々精進XP

よかろうもん

Rspec カスタム抹茶 (マッチャ) の点て方チュートリアル

tl;dr

awspec のコードを見ていて, どんな風に独自のマッチャを実装しているのか, ずーっと気になっていたので, Rspec のカスタム抹茶を点てる方法について調べてみたのと, 簡単なサンプル抹茶を点ててみたメモです.

茶器

以下の茶器 (環境) を利用します.

$ ruby --version
ruby 2.5.0p0 (2017-12-25 revision 61468) [x86_64-linux]

$ bundle exec rspec --version
RSpec 3.7
  - rspec-core 3.7.1
  - rspec-expectations 3.7.0
  - rspec-mocks 3.7.0
  - rspec-support 3.7.1

awspec でのカスタムマッチャ

例えば, 以下のようなマッチャが定義されています.

# https://github.com/k1LoW/awspec/blob/master/lib/awspec/matcher/have_db_parameter_group.rb
RSpec::Matchers.define :have_db_parameter_group do |name|
  match do |db_instance_identifier|
    db_instance_identifier.has_db_parameter_group?(name, @parameter_apply_status)
  end

  chain :parameter_apply_status do |parameter_apply_status|
    @parameter_apply_status = parameter_apply_status
  end
end

とある RDS DB インスタンス (my-rds) にパラメータグループ default.mysql5.6 が付与されているか, ステータス (parameter_apply_status) は pending-reboot となっていることをテストするマッチャです.

以下のように利用します.

# https://github.com/k1LoW/awspec/blob/master/spec/type/rds_spec.rb
describe rds('my-rds') do
...
  it { should have_db_parameter_group('default.mysql5.6') }
  it do
    should have_db_parameter_group('default.mysql5.6')\
      .parameter_apply_status('pending-reboot')
  end
end

抹茶を点てる

RSpec::Matchers.define による抹茶の定義

RSpec::Matchers.define を利用することで, 以下のようなマッチャを定義することが出来ます.

require 'rspec/expectations'

RSpec::Matchers.define :have_word do |expected|
  match do |actual|
    actual.include?(expected)
  end
end

RSpec.describe 'foo-bar-bar' do
  it { should have_word('foo') }
end

match ブロック内に現在の状態 (actual) とあるべき状態 (expected) を比較するロジックを記載すれば良さそうです.

これを実行すると, 以下のように出力されます.

$ bundle exec rspec sample1-1.rb

foo-bar-bar
  should have word "foo"

Finished in 0.00164 seconds (files took 0.15382 seconds to load)
1 example, 0 failures

念の為, 以下のように書いて, fail になることを確認します.

... 略 ...

RSpec.describe 'foo-bar-bar' do
  it { should have_word('baz') }
end

以下, 実行結果です.

$ bundle exec rspec sample1-1.rb

foo-bar-bar
  should have word "foo"

foo-bar-bar
  should have word "baz" (FAILED - 1)

Failures:

  1) foo-bar-bar should have word "baz"
     Failure/Error: it { should have_word('baz') }
       expected "foo-bar-bar" to have word "baz"
     # ./sample1-1.rb:14:in `block (2 levels) in <top (required)>'

Finished in 0.02444 seconds (files took 0.20172 seconds to load)
2 examples, 1 failure

LGTM.

chain を使って, マッチャを拡張する

先の例では, foo-bar-bar という文字列の中に, foo というワードが含まれていることをテストしていますが, foo というワードが 1 つ含まれていることをテストするコードを書くとすると, 以下のように書きたくなると思います. (少なくとも, 自分は書きたいと思います)

RSpec.describe 'foo-bar-bar' do
  it { should have_word('foo').count(1) }
end

これを実現する為に, chain を使って, チェーンするメソッドを定義することが出来ますので, 以下のように書いてみました.

require 'rspec/expectations'

RSpec::Matchers.define :have_word do |expected|
  match do |actual|
    if @num.nil?
      actual.include?(expected)
    else
      actual.split('-').count(expected) == @num
    end
  end

  chain :count do |num|
    @num = num
  end
end

chain ブロック内で count メソッドに渡された引数をインスタンス変数に代入しています.

  chain :count do |num|
    @num = num
  end

実際にテストを走らせてみます.

$ bundle exec rspec sample1-2.rb

foo-bar-bar
  should have word "foo"

foo-bar-bar
  should have word "foo"

Finished in 0.00133 seconds (files took 0.18253 seconds to load)
2 examples, 0 failures

異常値を count メソッドに定義して, fail になることも確認しておきます.

$ bundle exec rspec sample1-2.rb

foo-bar-bar
  should have word "foo"

foo-bar-bar
  should have word "foo" (FAILED - 1)

Failures:

  1) foo-bar-bar should have word "foo"
     Failure/Error: it { is_expected.to have_word('foo').count(2) }
       expected "foo-bar-bar" to have word "foo"
     # ./sample1-2.rb:23:in `block (2 levels) in <top (required)>'

Finished in 0.03156 seconds (files took 0.26405 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./sample1-2.rb:23 # foo-bar-bar should have word "foo"

LGTM.

fail 時のメッセージを定義する

fail した際のメッセージについてもカスタマイズしたメッセージを出力することが出来ます.

require 'rspec/expectations'

RSpec::Matchers.define :have_word do |expected|
  match do |actual|
    if @num.nil?
      actual.include?(expected)
    else
      actual.split('-').count(expected) == @num
    end
  end

... 略 ...

  failure_message do |actual|
    if @num.nil?
      "#{actual}#{expected} は含まれていない."
    else
      "#{actual}#{expected}#{@num} 個含まれていない."
    end
  end

end

failure_message ブロックに fail 時に出力したいメッセージを記載します.

実際に fail させてみると, 以下のように出力されます.

$ bundle exec rspec sample1-3.rb

foo-bar-bar
  should have word "foo"

foo-bar-bar
  should have word "foo" (FAILED - 1)

Failures:

  1) foo-bar-bar should have word "foo"
     Failure/Error: it { is_expected.to have_word('foo').count(2) }
       foo-bar-bar に foo は 2 個含まれていない.
     # ./sample1-3.rb:32:in `block (2 levels) in <top (required)>'

Finished in 0.04706 seconds (files took 0.26292 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./sample1-3.rb:32 # foo-bar-bar should have word "foo"

イイ感じです.

ヘルパーメソッドを利用する

以下のように書くことで, 検証ロジックを別メソッドに定義することが出来ます.

RSpec::Matchers.define :have_word do |expected|
  match do |actual|
    include_word?(actual, expected)
  end

  def include_word?(actual, expected)
    if @num.nil?
      actual.include?(expected)
    else
      actual.split('-').count(expected) == @num
    end
  end
... 略 ...
end

テストを実行してみます.

$ bundle exec rspec sample1-4.rb

foo-bar-bar
  should have word "foo"

foo-bar-bar
  should have word "foo"

Finished in 0.00129 seconds (files took 0.18528 seconds to load)
2 examples, 0 failures

LGTM.

以上

カスタム抹茶を点てるには...

  • RSpec::Matchers.define を利用する
  • match ブロック内に検証のロジックを書く
  • メソッドチェインを利用したい場合には chain ブロック内で引数をインスタンス変数に代入する
  • fail 時のメッセージは failure_message で上書きすることが出来る
  • ヘルパーメソッドを利用して, 検証のロジックを match ブロックから追い出すことも可能

という感じでしょうか.

参考

チュートリアルを行うにあたり, 以下のサイトを参考にさせて頂いております. 有難うございました.

relishapp.com

qiita.com