2013-11-18

通算3回目の参加・発表ということで大変お世話になっている 第4回 #渋谷java でお話してきました。5 分の LT では語れなかった内容が多々あるので、それらを盛り込んでエントリを残しておきます。

発表資料は以下です。

発表で使用する予定だったデモコードは komiya-atsushi/shibuya-java4 に置いておきました。

オブジェクト等価比較を怠けたくなった背景

事の発端

元々は SAStruts なとあるお仕事において、JSON を返却する Web API の Action クラスの各メソッドを JUnit でテストしたい、というのが事の発端でした。

このテストの本来の理想的な姿は、Tomcat などの AP サーバを経由して HTTP で実際に Web API を呼び出し、その結果のレスポンス の JSON が期待している内容と同一かどうかを比較する… というものでした。しかし、この構成を JUnit / S2JUnit4 で実施するにはなかなか難儀することが予想された(ぶっちゃけ面倒だった)ため、早々に Action クラスのメソッドを直接呼び出すことで得られる JSON 文字列を (デシリアライズして) アサーションする、という現実的な方針に切り替えました。

そういうわけで、Web API から返されるちょっと複雑なデータの JSON を Java のオブジェクトとして取り扱いつつ、それに対して期待している結果と等しいかどうかの等価比較 ( assertThat(actual, is(expected)); ) をすることが求められたわけですが… まあ Web API の戻り値として返すようなオブジェクトのクラスに Object#equals() がオーバーライド実装されていることなんてそうあるわけでもなく、ただひたすらにオブジェクトにプロパティ経由で値を参照して assertThat(actual.getHoge(), is(expected.getHoge()); する、みたいなテストコードの記述を強いられる残念な状況が生じたわけです(さすがに、さしみたんぽぽ作業をただひたすらに続けられるほど、僕はできているエンジニアではなかったので、2, 3 行試しに書いてみたところで見事に崩折れました)。

Object#equals() の実装

assertThat() の羅列が本当に羅列すぎて生きるのが辛いので、本来は equals() の実装は不要だけれども JUnit でのテストのために致し方なく本末転倒的にオーバーライドすることも念の為に考えてみました。

幸いにして、 Apache commons-langEqualsBuilder を使うと、比較的容易に equals() を実装することができます (commons-lang については こちらの資料 が参考になります)。特に、 EqualsBuilder#reflectionEquals() を使うと驚くほど簡単に equals() を実現できます。

しかし一方で、 EqualsBuilder を使って equals() を実装してアサーションを行うようにすると、アサーションに fail したときのメッセージが以下のように役立たずなメッセージになる、というデメリットが存在します。

java.lang.AssertionError: 
Expected: is <foo.bar.HogeBean@9a8d9b>
     but: was <foo.bar.HogeBean@22c6bb6c>

Matcher による解決の試み

Object#equals() を実装していないクラスのオブジェクトに対して等価比較なアサーションをしたい場合、 org.hamcrest.BaseMatcher クラスを継承実装するという手があります。この方法を利用すると、

  • 等価比較だけでなく、値の大小比較などのきめ細かいアサーションが可能になる。
  • fail したときの mismatch メッセージに不一致となった内容や各種コンテキスト情報などを載せることができ、fail メッセージからの不具合の原因追求が容易になる。
  • assertThat() まわりの記述がすっきり・シンプルになる。

というメリットを得ることができます。ただ、Web API のようにエンドポイントごとに返却されるオブジェクトが異なる場合などはエンドポイントの数だけクラスが実装されていることもあり、そのクラスの数だけ BaseMatcher クラスを継承実装するというのは現実的ではありません (そもそもの BaseMatcher の実装コストがかかる、というデメリットもあります)。

汎用的な等価比較用 Matcher を目指す

そういうわけで、「equals() を実装する」わけでもなく「個別 に Matcher を用意する」わけでもない方法で解決するアプローチとして、汎用的に利用できる等価比較用の Matcher である IsEquivalentTo を作成しました。

この Matcher を利用することで、

  • equals() の実装を必要とせず
  • もちろん独自 Matcher も実装不要で
  • assertThat() まわりのコードも比較的シンプルに保ち
  • 等価比較したくないプロパティ / フィールドも明示できる

テストコードの記述が可能になります! (なる予定です!)

Matcher の汎用化、というわけで、個別に Matcher を実装したときと比べてきめ細かなアサーションは実現が難しくなりますが、等価比較できるところだけをこの汎用化 Matcher を利用して比較し、等価比較ではないアサーションは別途 assertThat() の羅列などで対処する、というアプローチもとれることでしょう。

技術的だったり細かい話

IsEquivalentTo の実装では、以下の工夫を盛り込みつつ、等価比較の汎用化を実現しています。

  • 再帰的なオブジェクトのトラバース ... オブジェクトを木構造なデータ構造と見立て、List / Map / 配列 / その他オブジェクト、の 4 種類ごとに等価比較の戦略を分けています。List / Map / 配列であれば、単純に保有しているオブジェクトに対して等価比較をするようにします。

  • オブジェクトの等価比較 ... Object#equals() をオーバーライド実装していないオブジェクトの場合、単純に equals() による等価比較をしてしまっては残念な結果になることは目に見えているので、以降に示す「プロパティの参照」「public フィールドの参照」を試すようにしています。一方で equals() をオーバーライド実装しているようであれば、その equals() を呼び出すようにします。

  • プロパティの参照 ... JavaBeans の規約に従って定義されているプロパティは、JavaBeans APIjava.beans.PropertyDescriptor を利用して、getter メソッド経由で値を読み出すようにしています。

  • public フィールドの参照 ... 最近だと、JavaBeans の規約にあえて従わず、public にフィールドを定義してクラスの外にフィールドをさらけ出すような実装を見かけることも、実際にそのような実装をすることも多くなってきました (MessagePack が要求するクラスがまさにそのようなフィールド構成ですね)。そのため、public なフィールドについてはリフレクション API を利用して、プロパティの場合と同じようにその内部の値を取得・参照するようにしています。

  • 例外的に扱いしたいプロパティ / フィールドなどの指定 ... テスト実行した日時の情報など、不定な値がオブジェクトに含まれる場合は、その値を無視して等価比較したくなるものです。そのようなケースを考慮して、 JSONPath 的な記法で等価比較したくない要素を指定できるようにしています。

  • 比較的親切な fail 時の mismatch メッセージ ... JUnit のテストケースを作成する上で重要なことの一つに、テストケースが fail したときに「何が原因で fail したのか?」ができる限り把握できるメッセージを出力することがあります。 IsEquivalentTo の実装では、オブジェクト階層を意識して、JSONPath 的な表現で、「オブジェクトのどこがおかしいのか?」が把握可能なメッセージ出力ができます。

会場参加者への逆質問

今回話題にした等価比較の問題、他の人はどういうアプローチをとっているのかなー? と気になったので、会場参加されている方々に 逆質問してみたところ、

  • equals() が実装されていることは確かに少ないけど、 toString() が実装されていることは結構あるので、文字列比較で済ませている。
  • 割り切ってテストはしない。

との回答をいただきました。不定な値がオブジェクトに紛れることがなければ、確かに toString() で比較できますね… 参考になります。

その他、感想などなど

  • build.xml と JSP はそろそろ滅んで欲しい!
  • Groovy / Grails に俄然興味が湧いてきた!
  • TokyoWebmining の新たな講師がゲットできそう!
  • 資格試験 (統計検定) の前日に発表予定を入れるのはもうやめよう…

最後に、会場提供の BizReach さん、そして主催の @seri_k さん、お疲れさまでした&ありがとうございました!!