読者です 読者をやめる 読者になる 読者になる

JUnitで自作Matcherを作ってみた

JUnitで自作Matcherを作ってみた

JUnitでは、ListやHash自体のチェック(xxxを含んでいる、リストが空)はできるが、
"返り値xxxxは次の配列の要素のいずれか"というチェックができないので、自作Matcherを作ってみることにした。

案外さっくりできたけど、例外チェック甘いので手裏剣(not マサカリ)を投げてくれればありがたいです。

環境

言語:Java 7
フレームワークJUnit 4.10

どうやら4.11から4.12にかけてhamcrest周りでいろいろ変更があったらしく、4.12で動かすには追加でライブラリが必要になります。
org.hamcrest.hamcrest-core 1.3

要件

  • メソッドを呼び出した返り値がenumで、定義されているenumのうち数種類のいずれかの可能性がある。
  • 文脈に沿ったassertThatを使用したい
    • 既に用意されている方法でやろうとするとassertThat([A, B, C], hasItem(returnVal))となり、expectedとactualが逆になるので文脈的におかしい。
    • assertThat(returnVal, is(includedIn([A, B, C]))) と書けるようなカスタムMatcherを用意する。

実装

こちらがMatcher宣言クラス。
import文は省略する。

package local.aibou.junit.matcher;

public class IsIncludedIn extends BaseMatcher<Status> {

    private final List<Status> statuses;
    private Object actual;

    public IsIncludedIn(List<Status> statuses) {
        this.statuses = statuses;
    }

    @Override
    public boolean matches(Object actual) {
        this.actual = actual;
        if(!(actual instanceof Status)) {
            return false;
        }
        for(Status status : this.statuses) {
            if(status == actual) {
                return true;
            }
        }
        return false;
    }

    @Override
    public void describeTo(Description description) {
        if(this.actual != null) {
            description.appendValue(this.actual);
            description.appendText(" is not included in ");
        }
        description.appendValueList("[ ", ", ", " ]", statuses);
    }

    public static Matcher<Status> includedIn(List<Status> statuses) {
        return new IsIncludedIn(statuses);
    }
}

要は、一番最後のstaticメソッドincludedInで、matchesdescribeToを実装したMatcher<T>クラスを返すようなメソッドを定義してやればいい。
なお、このTクラスはactualのクラスと同じクラス(と子クラスも可能?)である必要がある。

上記クラスを使用したテストコードが以下

@Test
public void isIncludedInテスト(){
    List<Status> list = Arrays.asList(Status.Hoge, Status.Fuga, Status.Piyo);
    assertThat(Status.Hoge, is(includedIn(list)));
    assertThat(Status.Other, is(not(includedIn(list))));
}

Java 8だとラムダ式が使えそうな気がするので、より完結なコードになる可能性もある。

実行結果

テスト成功時はなにも表示されないので、テスト失敗時にどういう出力がされるか、出力結果をコピペする。
(上記最終行のis(not())is()に変更して実行した。)

java.lang.AssertionError: 
Expected: is <Status.Other> is not included in [ <Status.Hoge>, <Status.Fuga>, <Status.Piyo> ]
     got: <Status.Other>
   <Click to see difference>



    at org.junit.Assert.assertThat(Assert.java:780)
    at org.junit.Assert.assertThat(Assert.java:738)
    at local.aibou.AppTest.isIncludedInテスト(AppTest.java:23)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:45)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:42)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:20)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:263)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:68)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:47)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:231)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:60)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:229)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:50)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:222)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:300)
    at org.junit.runner.JUnitCore.run(JUnitCore.java:157)
    at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:74)
    at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:202)
    at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:65)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:120)

is <Status.Other> is not included in [ <Status.Hoge>, <Status.Fuga>, <Status.Piyo> ] ってその通りじゃん、 という感じだが、descriptionToメソッド内容が出力されてしまう。

ここは非常に難しい問題だと思う。後述参照

ちなみに、describeToメソッドが空の場合はこんな感じの出力になる。

java.lang.AssertionError: 
Expected: is 
     got: <Status.Other>
   <Click to see difference>



    at org.junit.Assert.assertThat(Assert.java:780)
    at org.junit.Assert.assertThat(Assert.java:738)
    at local.aibou.AppTest.isIncludedInテスト(AppTest.java:23)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:45)
    at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:15)
    at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:42)
    at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:20)
    at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:263)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:68)
    at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:47)
    at org.junit.runners.ParentRunner$3.run(ParentRunner.java:231)
    at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:60)
    at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:229)
    at org.junit.runners.ParentRunner.access$000(ParentRunner.java:50)
    at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:222)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:300)
    at org.junit.runner.JUnitCore.run(JUnitCore.java:157)
    at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:74)
    at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:202)
    at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:65)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:120)

Expected節が is しか表示されなくなるので、こちらも非常に気持ち悪い。

考察

パッケージ名をどうするか

Projectのルートとなるパッケージの配下でjunitというパッケージを切ればよさそう。
例)Project名がinner-apiとかなら、jp.ameba.kiban.innerapi.junitとか

カスタムMatcher自体のテストはどうするか

もちろんするべき。
actual はObject型で受け取るので、期待とは違うクラスやNULLなどの例外チェックはしておいたほうがいいと思われる。
パッケージ名は上記参照。

JUnitのバージョンについて

上記でも述べたが、4.11から4.12にかけて破壊的な変更が加えられているので、 hamcrest-coreパッケージ追加するだけで動作するかの保証は正直できない。 各プロジェクトにあわせて設定しよう。

共通JUnitライブラリを作るべきか

事業部等で使いまわす用の共通JUnitカスタムMatcherライブラリを用意すべきかという話。
便利なものがあればいいが、正直なところ微妙。
パッと考える限り、事業部共通で使える=OSSとして公開できる代物であると考えられるし、 (今回の実装例にもある通り、)プロジェクト内にとどまるようなカスタムMatcherになると考えられる。 つまり、いちプロジェクトものか、万人が使えるものかという二極になると思う。

自分の担当プロジェクトはよくxxx-api(xxxはプロジェクト名)やxxx-coreというリポジトリで区切られているので、xxx-coreに追加すればよさそう。

Descriptionはどう書くべきか

正直なとこと、よくわかっていない。
出力形式がかなり制限されているので、テスト失敗時のメッセージが非常に見難いものになる可能性が高い。
これはカスタムMatcherの宿命な気もするので、今後に期待といったところか。

結論

Rubyで開発したいです