読者です 読者をやめる 読者になる 読者になる

有頂天Ruby

ビールを飲みながらRubyについて書きます。

RSpec 3の新機能: コンポーザブルマッチャー

Myron Marston » New in RSpec 3: Composable Matchersの訳です。

あかんところあったらTwitter@nilp_までお願いします

RSpec 3の、最も大きな新機能が3.0.0.beta2で公開されました: コンポーザブルマッチャ(組み合わせ可能なマッチャー)です。 これにより強力で壊れづらい検証を書けるようになり、新たな可能性が開けました。

RSpec 2.xでは、私はこのようなコードを度々書いてきました。

class BackgroundWorker
  attr_reader :queue

  def initialize
    @queue = []
  end

  def enqueue(job_data)
    queue << job_data.merge(:enqueued_at => Time.now)
  end
end
describe BackgroundWorker do
  it 'puts enqueued jobs onto the queue in order' do
    worker = BackgroundWorker.new
    worker.enqueue(:klass => "Class1", :id => 37)
    worker.enqueue(:klass => "Class2", :id => 42)

    expect(worker.queue.size).to eq(2)
    expect(worker.queue[0]).to include(:klass => "Class1", :id => 37)
    expect(worker.queue[1]).to include(:klass => "Class2", :id => 42)
  end
end

RSpec 3では、コンポーザブルマッチャーを使うと、マッチャーを引数として(あるいは、データ構造の中にネストさせて)、他のマッチャーに渡すことができ、これにより次のようにシンプルにspecを書けるようになります

describe BackgroundWorker do
  it 'puts enqueued jobs onto the queue in order' do
    worker = BackgroundWorker.new
    worker.enqueue(:klass => "Class1", :id => 37)
    worker.enqueue(:klass => "Class2", :id => 42)

    expect(worker.queue).to match [
      a_hash_including(:klass => "Class1", :id => 37),
      a_hash_including(:klass => "Class2", :id => 42)
    ]
  end
end

我々は次のようなケースでも、失敗時のメッセージの読みやすさを保つため、inspectの結果ではなく、与えられたマッチャーのdescriptionを使うようにしました。 例えば、もし我々がqueue <<の行をコメントアウトして、このspecでテストしている実装を壊してしまったとき、specは次のように失敗します:

1) BackgroundWorker puts enqueued jobs onto the queue in order
   Failure/Error: expect(worker.queue).to match [
     expected [] to match [(a hash including {:klass => "Class1", :id => 37}), (a hash including {:klass => "Class2", :id => 42})]
     Diff:
     @@ -1,3 +1,2 @@
     -[(a hash including {:klass => "Class1", :id => 37}),
     - (a hash including {:klass => "Class2", :id => 42})]
     +[]

   # ./spec/background_worker_spec.rb:19:in `block (2 levels) in <top (required)>'

マッチャーエイリアス

もう気づいたかもしれませんが、上記の例ではa_hash_includingincludeの代わりに使っていました。

RSpec 3は、同じようなエイリアスをすべてのビルトインマッチャーに与えており、それにより文章としての読みやすさと、よりよい失敗時のメッセージが提供されます。 例えば、この検証と、失敗時のメッセージを比べてみましょう:

x = "a"
expect { }.to change { x }.from start_with("a")
expected result to have changed from start with "a", but did not change
x = "a"
expect { }.to change { x }.from a_string_starting_with("a")
expected result to have changed from a string starting with "a",
but did not change

a_string_starting_withstart_withにくらべより冗長な一方、失敗時のメッセージは本当に読み下しやすく、「日本語でおk」とならずにすみます。 我々は1つあるいはもっと多くのエイリアスをすべてのRSpecのビルトインマッチャーに与えました。 我々は、一環した表現を使うように努めているので(だいたいは "a [オブジェクトの型] [動詞]ing"のかたち)、どんなエイリアスがあるか推測するのは楽です。

このあといくつも例を出すので、あなたはそれを見ることができますし、RSpec 3のドキュメントにはすべてのリストを載せる予定です。 また、(RSpecのビルトインマッチャーであるかカスタムマッチャーであるかにかかわらず)自分自身で手軽にエイリアスを定義できるpublicなAPIがあります。 これはrspec-expectationsの中で、start_withエイリアスa_string_withを提供するためのコードです:

RSpec::Matchers.alias_matcher :a_string_starting_with, :start_with

Compound Matcher Expressions(マッチャー合成式)

Eloy Espinacoは、新機能として、マッチャーを組み合わせるための別の手段を提供するのに貢献しました: andorによるマッチャーの合成です。 例えば、あなたはこうやって書かずに:

expect(alphabet).to start_with("a")
expect(alphabet).to end_with("z")

2つのマッチャーを組み合わせて1つのマッチャーにすることが出来ます。

expect(alphabet).to start_with("a").and end_with("z")

もちろん、orでも同じことができます。あまり一般的ではないかもしれませんが、これは、正しい値のリストを表現するのにすごく便利です(例えば、検証したい値がはっきりしない場合)

expect(stoplight.color).to eq("red").or eq("green").or eq("yellow")

私は、これは特に、Jim Weirichのrspec-givenを使ってInvariantsを表現するのに便利なんじゃないかと考えています。 もちろん、マッチャー合成式を、引数として他のマッチャーに渡すことも出来ます:

expect(["food", "drink"]).to include(
  a_string_starting_with("f").and ending_with("d")
)

注: この例の、ending_withend_withマッチャーのエイリアスです

どのマッチャーが、マッチャー引数をサポートしてる?

RSpec 3では、我々は引数としてマッチャーを受け取れるよう、たくさんのマッチャーを更新しました、しかしすべてというわけではありません。

おおむね、わたしたちが納得できるようなものはすべて更新しました。 マッチャー引数をサポートしないマッチャーの1つに、マッチャー引数をうけつけることが出来ないことが明確なものがあります。

例えば、eqマッチャーは、actual == expectedの場合にのみ真になります。 そのため、eqがマッチャー引数をサポートすることはできません。 私は、マッチャー引数をサポートするすべてのビルトインマッチャーのリストを作りました↓

change

changeマッチャーのbyメソッドは、マッチャー引数を受け取れます。

k = 0
expect { k += 1.05 }.to change { k }.by( a_value_within(0.1).of(1.0) )

同様にfromtoにもマッチャー引数を渡すことができます

s = "food"
expect { s = "barn" }.to change { s }.
  from( a_string_matching(/foo/) ).
  to( a_string_matching(/bar/) )

contain_exactly

contain_exactlyは、match_arrayの新しいエイリアスです。 match_arrayよりも少し意味が明確です(いま、matchでも同じように配列にマッチすることができます、しかしmatchの方は順番を気にする必要があります、一方match_arrayでは気にする必要はありません)

また、配列の要素をそれぞれ、個別の引数として渡すことができます。match_arrayのように、1つの配列に詰め込むことを強いらません。

expect(["barn", 2.45]).to contain_exactly(
  a_value_within(0.1).of(2.5),
  a_string_starting_with("bar")
)

# ...which is the same as:

expect(["barn", 2.45]).to match_array([
  a_value_within(0.1).of(2.5),
  a_string_starting_with("bar")
])

include

includeでは、コレクションの要素、ハッシュのキー、ハッシュの中のkey/valueペアのサブセットに対するマッチが出来ます。

expect(["barn", 2.45]).to include( a_string_starting_with("bar") )

expect(12 => "twelve", 3 => "three").to include( a_value_between(10, 15) )

expect(:a => "food", :b => "good").to include(
  :a => a_string_matching(/foo/)
)

match

文字列と正規表現、文字列と文字列のマッチに付け加えて、今ではarray/hashのデータ構造に対してもうまく動きます、好きなだけネストしまくっていてもです。 マッチャーは、ネストのどの階層でも使うことができます。

hash = {
  :a => {
    :b => ["foo", 5],
    :c => { :d => 2.05 }
  }
}

expect(hash).to match(
  :a => {
    :b => a_collection_containing_exactly(
      an_instance_of(Fixnum),
      a_string_starting_with("f")
    ),
    :c => { :d => (a_value < 3) }
  }
)

raise_error

raise_errorが、例外クラスに対するマッチャーか、例外のメッセージに対するマッチャー、あるいはその両方を受け付けるようになりました。

start_with and end_with

これをみれば一目瞭然:

expect(["barn", "food", 2.45]).to start_with(
  a_string_matching("bar"),
  a_string_matching("foo")
)

expect(["barn", "food", 2.45]).to end_with(
  a_string_matching("foo"),
  a_value < 3
)

throw_symbol

Symbol以外の他の引数に対してマッチするマッチャーをthrow_symbolに渡せます:

expect {
  throw :pi, Math::PI
}.to throw_symbol(:pi, a_value_within(0.01).of(3.14))

yield_with_args と yield_successive_args

これらのマッチャー(yield_with_args/yield_successive_args)の、yieldに渡された引数を明示するのに、マッチャーを使うことができます:

expect { |probe|
  "food".tap(&probe)
}.to yield_with_args( a_string_starting_with("f") )

expect { |probe|
  [1, 2, 3].each(&probe)
}.to yield_successive_args( a_value < 2, 2, a_value > 2 )

まとめ

これは、RSpec 3の新機能の1つです。 私はこの機能に一番わくわくしました、あなたにもこの興奮が伝わるといいなぁと思っています。 この記事が、あなたが必要なことだけを書けばそれ以外のことを書かなくてよくなる(壊れやすいテストを書くのを避ける)手助けになればいいなあと思います。