Tech Racho エンジニアの「?」を「!」に。
  • 開発

RSpec: コンポジションできる`be_json`マッチャー(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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(コンポジション可能)だからです(=組み合わせてもうまく動くよう設計されている)。

  1. 一般的な「case文の等号」パターンマッチング演算子である===が提供されている(Rubyの中でも優秀で、かつ一部のチュートリアルで勧められているにもかかわらずなかなか使われていない機能)
  2. ===は内部のすべての値のマッチに使われている(ハッシュのキーや値など)ため、この演算子で定義されたものはどんなものであれ最終的に適切に使われる: つまり、マッチャーを単純な値に用いてもよいし、次のように正規表現/範囲/クラスはもちろん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)
)

関連記事

Ruby: 4種類の同等性比較: equal?/eql?/==/===(翻訳)

Ruby: ありそうでなかったRubyリファレンスの決定版を作った(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。