Help us understand the problem. What is going on with this article?

RSpecでカスタムマッチャを作る

More than 5 years have passed since last update.

カスタム:tea:の作り方を勉強したのでその備忘録。

確認したバージョン

$ ruby --version
ruby 2.1.3p242 (2014-09-19 revision 47630) [x86_64-darwin12.0]

$ gem list | grep rspec
rspec (3.1.0)
rspec-core (3.1.7)
rspec-expectations (3.1.2)
rspec-mocks (3.1.3)
rspec-support (3.1.2)

新しいマッチャを定義する RSpec::Matchers.define

カスタムマッチャを定義するMatcher DSLと呼ばれるRSpec::Matchers.defineが用意されている。

require "nokogiri"

RSpec::Matchers.define :have_tag do |tag_name|
  match do |html_string|
    doc = Nokogiri::HTML(html_string)
    !doc.css(tag_name.to_s).empty?
  end
end

describe "カスタムマッチャーを作ってみるよ" do
  subject { '<html><body><div>contents</div></body></html>' }
  it { is_expected.to have_tag :div }
end

実行!

$ rspec . 
.

Finished in 0.00089 seconds (files took 0.15038 seconds to load)
1 example, 0 failures

できた!

検証失敗時のメッセージを設定する

検証失敗となるテストを追加する。

describe "カスタムマッチャーを作ってみるよ" do
  :
  it { is_expected.to have_tag :p }
end

実行。

$ rspec . 
.F

Failures:

  1) カスタムマッチャーを作ってみるよ should have tag :p
     Failure/Error: it { is_expected.to have_tag :p }
       expected "<html><body><div>contents</div></body></html>" to have tag :p
     # ./a_spec.rb:17:in `block (2 levels) in <top (required)>'

expected "<html><body><div>contents</div></body></html>" to have tag :pとそこそこリーダブルだがHTMLソースが巨大だったら目が眩む。failure_messageで検証失敗時のメッセージが設定できる。

RSpec::Matchers.define :have_tag do |tag_name|
  :
  failure_message do |html_string|
    %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が含まれる}
  end
end

実行結果のメッセージは以下のようになる。

  1) カスタムマッチャーを作ってみるよ should have tag :p
     Failure/Error: it { is_expected.to have_tag :p }
       HTML( "<html><body><div>con..." )にタグ "p" が含まれる
     # ./a_spec.rb:17:in `block (2 levels) in <top (required)>'

否定の検証(expect().toではなくexpect().not_to)失敗時のメッセージを設定する

failure_message_when_negatedで設定する。

RSpec::Matchers.define :have_tag do |tag_name|
  :
  failure_message_when_negated do |html_string|
    %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が含まれない}
  end
end

Documentationフォーマットの見栄えをよくする

ワンライナーシンタックス(itの引数にdescriptionを渡さない書き方)の場合、-f dで実行するDocumentationフォーマットが依然として機械的に構築された文章になっている。

$ rspec . -f d

カスタムマッチャーを作ってみるよ
  should have tag :div
  should not have tag :p

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

descriptionで任意の文字列を設定できる。

RSpec::Matchers.define :have_tag do |tag_name|
  :
  description do |html_string|
    %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が含まれる}
  end
end

これで見栄えがよくなった。

カスタムマッチャーを作ってみるよ
  should HTML( "<html><body><div>con..." )にタグ "div" が含まれる
  should not HTML( "<html><body><div>con..." )にタグ "p" が含まれる

先頭のshoudshould notはどうにもならんのかな...(やはり日本語で書くのはイケてない:see_no_evil:)。

メソッドチェーンで自然言語風DSLなインターフェースにする

例えばこういうの。

it { is_expected.to have_tag(:div).exactly(2).elements }

要素の有無だけではなく、要素数も併せて検証可能としてみる。

chainでチェーンできるメソッドが定義できる。

RSpec::Matchers.define :have_tag do |tag_name|
  :
  chain :exactly do |num|
    @num = num
  end

  # 以下は副作用なし、見栄えのためだけに定義
  chain :elements do; end
  chain :element do; end
end

引数の値を集めておいて、matchブロック内でそれを利用する。
併せてfailure_messageにもその値を反映させよう。

RSpec::Matchers.define :have_tag do |tag_name|
  match do |html_string|
    doc = Nokogiri::HTML(html_string)
    if @num.nil?
      !doc.css(tag_name.to_s).empty?
    else
      doc.css(tag_name.to_s).size == @num
    end
  end

  failure_message do |html_string|
    if @num.nil?
      %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が含まれる}
    else
      %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が #{@num} 含まれる}
    end
  end
  :
end

これで以下のように書ける。

describe "カスタムマッチャーを作ってみるよ" do
  subject { '<html><body><div>contents</div></body></html>' }
  :
  it { is_expected.to have_tag(:div).exactly(1).elements }
  it { is_expected.to have_tag(:div).exactly(2).elements }
end

実行!

$ rspec .
...F

Failures:

  1) カスタムマッチャーを作ってみるよ should HTML( "<html><body><div>con..." )にタグ "div" が含まれる
     Failure/Error: it { is_expected.to have_tag(:div).exactly(2).elements }
       HTML( "<html><body><div>con..." )にタグ "div" が 2 含まれる
     # ./a_spec.rb:43:in `block (2 levels) in <top (required)>'

否定の検証ロジックを通常の検証ロジックと別々にする

match_when_negatedを定義すると、not_toの場合にmatchの結果を反転するのではなく、そちらの検証ロジックを利用するようになる。通常の検証ロジックと否定の検証ロジックを別々にしたほうが良い場合に利用する。

RSpec::Matchers.define :have_tag do |tag_name|
  match do |html_string|
    doc = Nokogiri::HTML(html_string)
    if @num.nil?
      !doc.css(tag_name.to_s).empty?
    else
      doc.css(tag_name.to_s).size == @num
    end
  end

  # このサンプルのロジックであれば不要だが、あくまでサンプルなので記述
  match_when_negated do |html_string|
    doc = Nokogiri::HTML(html_string)
    if @num.nil?
      doc.css(tag_name.to_s).empty?
    else
      node_set = doc.css(tag_name.to_s)
      node_set.empty? or doc.css(tag_name.to_s).size != @num
    end
  end
  :
end

match_when_negatedではtrueを返却するとnot_toの検証をパスする。

サンプルのカスタムマッチャ+テストケース全容

以下の通り。

require "nokogiri"

RSpec::Matchers.define :have_tag do |tag_name|
  match do |html_string|
    doc = Nokogiri::HTML(html_string)
    if @num.nil?
      !doc.css(tag_name.to_s).empty?
    else
      doc.css(tag_name.to_s).size == @num
    end
  end

  match_when_negated do |html_string|
    doc = Nokogiri::HTML(html_string)
    if @num.nil?
      doc.css(tag_name.to_s).empty?
    else
      node_set = doc.css(tag_name.to_s)
      node_set.empty? or doc.css(tag_name.to_s).size != @num
    end
  end

  failure_message do |html_string|
    if @num.nil?
      %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が含まれる}
    else
      %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が #{@num} 含まれる}
    end
  end

  failure_message_when_negated do |html_string|
    %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が含まれない}
  end

  description do |html_string|
    %Q{HTML( "#{html_string.sub(/(?<=.{20})(.+)$/, '...')}" )にタグ "#{tag_name.to_s}" が含まれる}
  end

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

  chain :elements do; end
  chain :element do; end
end

describe "カスタムマッチャーを作ってみるよ" do
  subject { '<html><body><div>contents</div></body></html>' }
  it { is_expected.to have_tag :div }
  it { is_expected.not_to have_tag :p }
  it { is_expected.to have_tag(:div).exactly(1).elements }
  it { is_expected.not_to have_tag(:div).exactly(2).elements }
  it { is_expected.not_to have_tag(:p).exactly(0).elements }
  it { is_expected.not_to have_tag(:p).exactly(1).elements }
end

雑記

RSpec::Matchers.defineのブロック内DSL

RSpec::Matchers::DSL::Macrosで定義されている。

Matcherプロトコル

Matcher DSLではなく特定のメソッドを持つクラスを定義することでカスタムマッチャを作成できる。

  • matches?
  • failure_message

を最低限実装する必要があり、全てのI/FはRSpec::Matchers::MatcherProtocolに定義されている。

例えばビルトインのeqマッチャは実態はRSpec::Matchers::BuiltIn::EqクラスでMatcherプロトコルに準拠している。

failure_message_for_shouldどこいった?

3.0.0.beta2でdeprecatedになっている。deprecatedになっているメソッドはRSpec::Matchers::DSL::Macros::Deprecatedに集められて、利用するとwarning吐くようになっている。

Matcher DSLとMatcherプロトコル、どう使い分けるべき?

まだ分からん。Matcherプロトコルのほうが柔軟に書けそうだが、どういったケースでそのメリットを享受できるのかがイメージがついていない。誰か教えて...。

classi
学校の先生・生徒・保護者向けのB2B2Cの学習支援Webサービス「Classi(クラッシー)」 を開発・運営している会社です。
https://classi.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした