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

RspecでJSON形式のハッシュをFactoryGirlなどで用意したデータでテストする際に使えるCustomMatcherを作った。

More than 3 years have passed since last update.

Shinosaka.rb Advent Calendar 24日目の記事です。
こちらの記事は使い方までなら15分ほどで読めます。

動作環境

$ rails -v
$ Rails 5.0.0.1
$ ruby -v
$ ruby 2.3.1p112 (2016-04-26 revision 54768) [x86_64-linux]

で動作確認済み。

はじめに(具体例)

RailsでAPIを作成した際のJSON形式のハッシュのテストに使えるRspecのCustomMatcherになります。
下記のようにJSON形式のハッシュのテストを書くことができます。

tests_controller.rbがあるとします。

・JSON形式のハッシュのkeyとvalueの一致のテスト(key順不同でOK)
#match_hash_after_jsonalized
spec/requests/tests_spec.rb
# subject:テストしたいJSON形式のハッシュ。ここでは
#        {name => 'Name', description => 'Description'}
#
# data:テストするために用意したハッシュ。ここでは
#      {name => 'Name', description=> 'Description',
#       created_at => '*****', updated_at => '*****'}
# dataはDBに一旦保存してから取り出した場合を想定してます。

subject {JSON.parse(response.body)}
expect(subject).to match_hash_after_jsonalized(
       data, 
       # specを無視したいkeyを文字列で加えてください。(数は何個でもOK)
       # 何も追加しない場合はnilを追加してください。
       'created_at', 'updated_at'
)

# => 1 example, 0 failures

仮にupdated_atを加え忘れたら以下のようなエラーを履きます。

{
   json at key 'updated_at' is not contained in expected keys.
   please have to include 'updated_at' to arguments of match_hash_keys.

            expected:
                 got: updated_at
}

・JSON形式のハッシュのkeyの数と値の一致のテスト(順不同でOK)
#match_hash_keys
spec/requests/tests_spec.rb
# subject:テストしたいJSON形式のハッシュ。ここでは
#        {name => 'Name', description => 'Description'}
#
# data:テストするために用意したハッシュ。ここでは
#      {name => 'Name', description=> 'Description',
#       created_at => '*****', updated_at => '*****'}
# dataはDBに一旦保存してから取り出した場合を想定してます。

subject {JSON.parse(response.body)}
expect(subject).to match_hash_keys(
       data, 
       # specを無視したいkeyを文字列で加えてください。(数は何個でもOK)
       # 何も追加しない場合はnilを追加してください。
       'created_at', 'updated_at'
)

# => 1 example, 0 failures

仮にupdated_atを加え忘れたら以下のようなエラーを履きます。

{
   json at key 'updated_at' is not contained in actual keys.
   please have to include 'updated_at' to arguments of match_hash_keys.

            expected: updated_at
                 got:
}

使い方

gemにするにはできることがまだ少ないので、Gistとして公開しております。
GitHub Gist match_hash.rb
※注意:本コードは、kroehre/ gist:9cc209bcf43a1d1b001bを改造したものになります。

仮定: railsのプロジェクトを作成しており、rspecを使っているものとします。 

$ mkdir -p spec/support
$ cd spec/support
$ curl -L -O https://gist.githubusercontent.com/yukihirop/be877a085e13ddcac47ece534535a5ad/raw/4a26f5fddb2c1575988baa5d115b5cffdfaa06e4/match_hash.rb -o match_hash.rb

あとは他のCustomMatcherを使うように、***_spec.rbファイルで使えばOKです。

コード解説

実際に使われてみて構造が気になられる方は以下を御覧ください。
以下の前提を満たしていないと読むのがきついかもしれません。

前提: 
・何かしらのCustomMatcherを作ったことがある。
・CustomMatcherの作り方を知っている。

CustomMatcherの作り方に関してはこちらの記事が参考になりました。
Rspecでカスタムマッチャを作る

Gistを見るとわかるように結構長いので、Method名だけを残したコードを以下に用意しました。(詳しい内容を...で省略する。IDEの機能によくある表示です。)

match_hash.rb
# @abstract           json形式のhashのkeyの数と値、hashの構造、keyとvalueのペアの一致(hash完全一致、順不同)
#                     などをテストする.
RSpec::Matchers.define :match_hash_after_jsonalized do |un_jsonalized_expected, *spec_ignore_keys|
  include CustomMatcherSubMethods

  errors = []
  jsonalized_expected = JSON.parse(un_jsonalized_expected.to_json)
  ignore_spec = proc{|key| if spec_ignore_keys.include?(key) then true end}

  match do |actual|
    errors = match_hash(jsonalized_expected, actual, [], nil){...}
    errors << is_expected_key_exists?(jsonalized_expected, actual, [], nil){...}
    errors << is_actual_key_exists?(jsonalized_expected, actual, [], nil){...}
    errors.flatten!
    errors.empty?
  end

### ここから

  description do |actual|
    "matches hash #{expected}"
  end

  failure_message do |actual|
    errors.join("\n")
  end

### ここまで
### はあまり重要じゃない。

end

# @abstract           json形式のhashのkeyの数と値の一致をテストする.(keyの完全一致、順不同).
RSpec::Matchers.define :match_hash_keys do |un_jsonalized_expected, *spec_ignore_keys|

  errors = []
  jsonalized_expected_keys = JSON.parse(un_jsonalized_expected.to_json).keys
  ignore_spec = proc{|value| if spec_ignore_keys.include?(value) then true end}

  match do |actual|
    actual_keys = actual.keys
    # expectedとactual文字列だったりシンボルだったりするので文字列に統一
    jsonalized_expected_keys.map!{|s| s.to_s}
    actual_keys.map!{|s| s.to_s}
    errors = match_array(jsonalized_expected_keys, actual_keys,[], nil){...}
    errors.flatten!
    errors.empty?
  end

### ここから

  description do |actual|
    "matches hash_keys #{expected}"
  end

  failure_message do |actual|
    errors.join("\n")
  end

### ここまでは
### あまり重要じゃない。

  def match_array(expected, actual, errors, path)...end

end

module CustomMatcherSubMethods

  private

  def is_expected_key_exists?(expected, actual, errors =[], path=nil)...end

  def is_actual_key_exists?(expected, actual, errors=[], pth=nil)...end

  def match_hash(expected, actual, errors = [], path = nil)...end

  def match_array(expected, actual, errors, path)...end

  def match_value(expected, actual, errors, path)...end

end

だいぶ見通しがよくなりました。

まず本コードにはCustomMatcherが2つあります。

・match_hash_after_jsonalized
・match_hash_keys

ひとつづつ解説します。

CustomMatcherでは、match do |actual|...end の中にテストしたいことを書きます。故にこの部分に注目して説明します。

Match_hash_after_jsonalized

match do |actual|...endの該当部分を抜き出します。

match do |actual|
    errors = match_hash(jsonalized_expected, actual, [], nil){
      |key| ignore_spec.call(key)
    }
    errors << is_expected_key_exists?(jsonalized_expected, actual, [], nil){
      |key| ignore_spec.call(key)
    }
    errors << is_actual_key_exists?(jsonalized_expected, actual, [], nil){
      |key| ignore_spec.call(key)
    }
    errors.flatten!
    errors.empty?
  end

・match_hash(){...}

jsonalized_expected(用意したdata(ハッシュ)をJSON.parseしたもの)をeachで回しvalueを取得し、CustomMatcherSubMethodsモジュールのmatch_value()を呼び出す。呼び出されたmatch_value()jsonalized_expected[key]ClassTypeを調べてHashならmatch_hash()を呼び出し、Arrayならmatch_array()を呼び出す。
もしmatch_array()が呼び出されたらeachで回し、要素を取得しmatch_value()を呼び出す。もし、ClassTypeHashでもArrayでもなければ、expected(値)actual(値)を比較し、違う場合はerrorsにエラーメッセージを格納する。返り値はerrors

・is_expected_key_exists?(){...}

jsonalized_expectedをeachで回しkeyを取得する。取得したkeyactual(テストしたいJSON形式のハッシュ)に含まれていないならerrorsにエラーメッセージを格納する。返り値はerrors

・is_actual_key_exists?(){...}

actual(ハッシュ)をeachで回しkeyを取得する。取得したkeyjsonalized_expected(ハッシュ)に含まれていないならerrorsにエラーメッセージを格納する。返り値はerrors。

・すべてのMethodに共通すること ({...}の部分)

eachで取得したkeyspec_ignore_keys(specを無視するkey配列)にある場合は
そのkeyに関しての処理を飛ばす。


以上のMethodを順番に実行し、得られたerrors(配列の配列)errors.flatten!でただの配列にしてerrors.empty?を実行。falseならfailure_message do |actual|...endが実行され、エラーメッセージが出力されます。

Match_hash_after_jsonalized

match do |actual|...endの該当部分を抜き出します。

match do |actual|
    actual_keys = actual.keys
    # expectedとactual文字列だったりシンボルだったりするので文字列に統一
    jsonalized_expected_keys.map!{|s| s.to_s}
    actual_keys.map!{|s| s.to_s}
    errors = match_array(jsonalized_expected_keys, actual_keys,[], nil){
      |value| ignore_spec.call(value)
    }
    errors.flatten!
    errors.empty?
  end

こちらには前処理の部分があります。errors=の前までが前処理になります。

・前処理の部分

1行目actual(テストしたいJSON形式のハッシュ)からactual_keys(keyの配列)を取得する。
3行目: jsonalized_expected_keys(用意したdata(ハッシュ)をJSON.parseしたもののkeyの配列)を全て文字列にする。
4行目: actual_keysをずべて文字列にする。

・match_array(){...}

これはCustomMatcherSubMethodsモジュールのmatch_array()とは違います。
大きく分けて行っていることが2つあります。(メソッドを2つにするか迷いました。)

  1. jsonalized_expected_keys(配列)をeach_with_indexで回して、valueを取得する。取得したvalueactual(配列)に含まれていないならerrorsにエラーメッセージを格納する。返り値はerrors。

  2. actual(配列)をeach_with_indexで回して、valueを取得する。取得したvaluejsonalized_expected_keys(配列)に含まれていないならerrorsにエラーメッセージを格納する。返り値はerrors。

・{...}の部分

eachで取得したkeyspec_ignore_keys(specを無視するkey配列)にある場合は
そのkeyに関しての処理を飛ばす。

最後に

もうちょっと機能を足してgem化しようと思います。

「バグやもうちょっとこうしたがいいんじゃないか」といったような
提案があればお願い致します。

以上です。長々と読んでいただきありがとうございました。

yukihirop
気の向くまま。意の向くままにコードを書くプログラマー。 役に立つツールを作るのって本当に難しい。
https://creator-of-what.yukihirop.me/
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
ユーザーは見つかりませんでした