概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Embracing composability: be_json RSpec matcher
- 原文公開日: 2018/03/02
- 著者: zverok -- 名サイト「rubyreferences.github.io」の作者でもあります。
- リポジトリ: zverok/saharspec
RSpec: コンポジションできるbe_json
マッチャー(翻訳)
HTTP APIをRSpecでテストするのは、簡単なようでときにはトリッキーになることがあります。レスポンスのJSONをRubyらしい正しいやり方でテストするにはどうすればよいでしょうか?おそらく誰もが最初は以下のように書くでしょう。
let(:response) { call_my_api }
subject { JSON.parse(response.body) }
it { is_expected.to ... }
しかしこれは退屈極まりない作業であるとすぐに気づきます。小さなエンドポイントを多数テストする場合や、以下のようにレスポンスに含まれる複数のプロパティを一括でテストする場合はなおさらです。
subject { response }
its(:status) { is_expected.to eq 201 }
its(:headers) { are_expected.to include('Location') }
its(:body) { is_expected.to ... what?.. }
注: RSpecのこの書き方(expectationや、descriptionなしの
it
や、rspec-its
を用いて1つのテスト=1ステートメントを厳守する)がつらかったり不慣れに思える場合は、私がチラシの裏に書いた理論の記事でこの書き方を私が好んでいる理由をご覧ください。既にit
に慣れ親しみ、「マジックの少ない」(☺️)饒舌なテストがお好みの方には、本記事が肌に合わないかもしれません。
このシンプルなJSONマッチャーのために、驚くほど多くの人々が聖杯を求めて旅立ちました(その1、その2、その3、その4、その5)。この中のいくつかは最終的に使ったものの、未だに「うん、動くけどね、やりたいのはこれじゃないのよね」という複雑な気持ちにかられます。
上述のgemの作者をdisる意図は毛頭ありませんのでお間違いのないようお願いします。私はテスト設計上の懸念点を調査しているだけです。
最終的にこちらのgemが「最も正しそう」に思え、著者といくつかやりとりをした結果、自分が正しいと思えるソリューションを思いつくに至りました。
実のところ、この問題はアホらしいぐらいシンプルです。expect(なんちゃら).to match_json(value)
のようにテストを書いてみれば、これは簡単な静的な値のテストとはわけが違うとすぐさま気付きます。むしろテストの対象はそのコンテキストにおいて、meta.nextPage
に含まれるURLがoffset=100
で終わるべきという「なんちゃら」なのです。この時点で、上述のgemはいずれもオレオレ記法やDSLを考案する傾向がありました(JSON内でマッチするとか、配列内の順序にかかわらずマッチするとか、特定のパスが存在することを確認するなど)。こうしたアプローチで自分が残念だと思う点は、何というか「JSONとこれっぽっちも関係のない」、値や構造の一般的なテストのように見える点です。それなら既にRSpecに実装されていてもよさそうなものです。
そしてひらめいたのです。私のアイデアのポイントは「マッチャーのcomposability(コンポーザビリティ)」であり、これはRSpec 3以後のRSpecの自然な機能として登場しました。
expect(response).to be_json('meta' => {})
expect(response).to be_json include('meta' => include('next' => end_with('offset=200')))
expect(response).to be_json hash_excluding('total')
これによって、ほとんどのユースケースで強力さを保ちつつ、マッチャー自体が極めて最小化されます(完全な定義はこちらをどうぞ)。
- 値は単純なものを用いる
- RSpecマッチャーを任意の深さで組み合わせて使える
include('items' => contain_exactly(String, String, kind_of(Hash).or(nil)))
- RSpecにあるrspec-mocksの引数マッチャーを他のマッチャーと組み合わせることもできる
include(hash_including(anything => duck_type(:each)))
- 成功の場合(
--format doc
モード)と失敗の場合(expectationと実際の結果に差が生じて文字どおり失敗する)のいずれについても、納得できるマッチャー出力を比較的簡単に得られる
これらがすべて可能なのは、RSpecのマッチャーがcomposable(コンポジション可能)だからです(=組み合わせてもうまく動くよう設計されている)。
- 一般的な「
case
文の等号」パターンマッチング演算子である===
が提供されている(Rubyの中でも優秀で、かつ一部のチュートリアルで勧められているにもかかわらずなかなか使われていない機能) ===
は内部のすべての値のマッチに使われている(ハッシュのキーや値など)ため、この演算子で定義されたものはどんなものであれ最終的に適切に使われる: つまり、マッチャーを単純な値に用いてもよいし、次のように正規表現/範囲/クラスはもちろんlambdaに使ってもよい(lambdaは別に便利ではないかもしれないが)
expect(response).to be_json(
"meta" => hash_including("next" => 1..4),
"result" => /success/i,
"items" => Array,
"total" => ->(t) { (t % 1000) * 18 < 46 }
)
「composability」からこんなに素晴らしいことを学べたのです。
マッチャーを実際に使う
上述のbe_json
マッチャーは、次のバージョンである私の作ったsaharspec RSpecアドオンライブラリに含まれています。その兄弟とも言えるbe_json_sym
gem(名前はイマイチなので、もっといい名前がないものでしょうか)も同じですが、こちらはsymbolize_names: true
でJSONをパースします。これにより、Rubyのハッシュキーの簡潔なシンタックスシュガーが使えるようになります。
expect(response).to be_json_sym(
meta: hash_including(next: 1..4),
result: /success/i,
...
)
ちゃぶ台返し
RSpecのマッチャーと、rspec-mocksの引数マッチャーの混用には制約があります。引数マッチャーにはナイスな#describe
がなく、.and
と組み合わせることもできません。しかし信頼できる情報筋によると、この不整合は今後のRSpecでマッチャーを統一する形で修正されるのだそうです。
おまけ: マッチャーをお手軽にコンポジションする
込み入ったデータ構造をテストする場合、be_an(Array).and(all(be_an(Integer)))
だのeq({next: 3}).or be_nil
だのといったこれまたややこしいテストをつい書いてしまうことがあります。以下でご紹介するのは、この手のコンポジションを単独のマッチャーに切り出すのに使える、構文上の少々そっけないトリックです。
RSpec::Matchers.define :be_array_of do |item|
match_unless_raises do |actual|
expect(actual).to be_an(Array).and(all(match(item)))
end
end
# これで以下のように書ける
expect(%w[foo bar]).to be_array_of(Integer)
# ["foo", "bar"]がIntegerの配列であるというexpectation
RSpec::Matchers.define :be_nullable_of do |matcher|
match_unless_raises do |actual|
expect(actual).to match(matcher).or be_nil
end
end
expect('test').to be_nullable_of(Integer)
# "test"がnullableなIntegerであるというexpectation
# 次のようにコンポジションできる
expect(response).to be_json(
"meta" => {"next" => be_nullable_of(Integer)},
"items" => be_array_of(Hash)
)