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
# 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
# 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の機能によくある表示です。)
# @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()__を呼び出す。もし、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()
とは違います。
大きく分けて行っていることが2つあります。(メソッドを2つにするか迷いました。)
-
jsonalized_expected_keys(配列)
をeach_with_indexで回して、value
を取得する。取得したvalue
がactual(配列)
に含まれていないならerrors
にエラーメッセージを格納する。返り値はerrors。 -
actual(配列)
をeach_with_indexで回して、value
を取得する。取得したvalue
がjsonalized_expected_keys(配列)
に含まれていないならerrors
にエラーメッセージを格納する。返り値はerrors。
・{...}の部分
eachで取得したkey
がspec_ignore_keys(specを無視するkey配列)
にある場合は
そのkey
に関しての処理を飛ばす。
#最後に
もうちょっと機能を足してgem化しようと思います。
「バグやもうちょっとこうしたがいいんじゃないか」といったような
提案があればお願い致します。
以上です。長々と読んでいただきありがとうございました。