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

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

```ruby: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

```ruby: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](https://gist.github.com/yukihirop/be877a085e13ddcac47ece534535a5ad)
※注意:本コードは、[kroehre/ gist:9cc209bcf43a1d1b001b](https://gist.github.com/kroehre/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でカスタムマッチャを作る](http://qiita.com/kozy4324/items/9a6530736be7e92954bc)

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

```ruby: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が２つあります。

```
・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()__を呼び出す。もし、`ClassType`が`Hash`でも`Array`でもなければ、`expected(値)`と`actual(値)`を比較し、違う場合は`errors`にエラーメッセージを格納する。返り値は`errors`。

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

#### ・is_actual_key_exists?(){...}
`actual(ハッシュ)`をeachで回し`key`を取得する。取得した`key`が`jsonalized_expected(ハッシュ)`に含まれていないなら`errors`にエラーメッセージを格納する。返り値はerrors。

#### ・すべてのMethodに共通すること ({...}の部分)
eachで取得した`key`が`spec_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()`とは違います。
大きく分けて行っていることが２つあります。(メソッドを２つにするか迷いました。)

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

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

#### ・{...}の部分
eachで取得した`key`が`spec_ignore_keys(specを無視するkey配列)`にある場合は
その`key`に関しての処理を飛ばす。

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

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

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