Posted at

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

More than 1 year has 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化しようと思います。

「バグやもうちょっとこうしたがいいんじゃないか」といったような

提案があればお願い致します。

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