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

倭マン's BLOG

くだらない日々の日記書いてます。 たまにプログラミング関連の記事書いてます。 書いてます。

ご注文は hamcrest-library ですか? ~Matcher 子々孫々編~

前回、TypeSafeMatcher を使って日付を比較検証するカスタム Matcher を作成しました。 Matcher を実装するために提供されている抽象クラスは TypeSafeMatcer いくつかあるので、今回はそれらを見ていきます。

この記事の内容

Matcher 型を実装するクラス

Matcher を実装するための抽象クラスをクラス階層をもとに列挙すると以下のようなものがあります:

  • Matcher<T>
    • BaseMatcher<T>
      • CustomMatcher<T>
      • DiagnosingMatcher<T>
      • TypeSafeMatcher<T>
        • CustomTypeSafeMatcher<T>
      • TypeSafeDiagnosingMatcher<T>
        • FeatureMatcher<T, U>

クラスとしては7つありますが、FeatureMatcher 以外は TypeSafe かどうかで分類すると、実質的に3種類だけです:

TypeSafe でない TypeSafe 用途
BaseMatcher TypeSafeMatcher Matcher の実装クラスを作るための基本的な抽象クラス
DiagnosingMatcher TypeSafeDiagnosingMatcher エラーメッセージを詳しく出力させることができる抽象クラス
CustomMatcher CustomTypeSafeMatcher 無名クラスで簡単に実装クラスを作成するための抽象クラス

FeatureMatcher については以下の実装例を参照。

実装あれこれ

以下では、基本的には TypeSafe な方のクラスで実装例を見ていきます。 実装例としては文字列の長さを指定して、検証対象の文字列がその長さになっているかを検証する HasLength Matcher を作成します。 同じ目的のクラスをいろいろな方法で実装しているとちょっと飽きますが、まぁ、違いを見るにはよいかと。

TypeSafeMatcher で実装
まずは TypeSafeMatcher での実装。 TypeSafeMatcher は前回に出てきたので、どんな Matcher を作ろうとしているのかを見るサンプルと思ってもらってもいいです。 実測値のエラーメッセージもわかりやすく(というか日本語で)出力させるために、describeMismatchSafely メソッドも実装します:

import org.hamcrest.*;

public class HasLength extends TypeSafeMatcher<String> {

    private final int length;

    private HasLength(int n){
        this.length = n;
    }

    @Override
    protected boolean matchesSafely(String s) {
        return s.length() == this.length;
    }

    @Override
    public void describeTo(Description desc) {
        desc.appendText("長さ");
        desc.appendValue(this.length);
        desc.appendText("の文字列");
    }

    @Override
    protected void describeMismatchSafely(String s, Description desc) {
        desc.appendValue(s);
        desc.appendText("の長さは");
        desc.appendValue(s.length());
        desc.appendText("です。");
    }

    @Factory
    public static Matcher<String> hasLength(int n){
        if(n < 0)
            throw new IllegalArgumentException("引数は0以上でなければなりません:実際の値は"+n);

        return new HasLength(n);
    }
}

matchesSafely メソッドで文字列の長さを検証しているところは特に問題ないと思います。 describeTo, describeMismatchSafely メソッドでエラーメッセージを追加しているときに appendTextappendValue を使い分けてますが、これは appendValue を使うとその値を "" や <> で囲って出力してくれるためです。 まぁ、別に全部テキストで出しても構いませんが。 この HasLength を使って以下のようなテストコード

public class HasLengthTest {

    @Test
    public void hasLengthで文字列の長さを検証する(){
        assertThat("JUnit", hasLength(5));
        assertThat("ScalaTest", hasLength(5));
    }
}

を実行すると、2行目の "ScalaTest" の行で検証が失敗して以下のようなエラーメッセージが出力されます:

java.lang.AssertionError:
Expected: 長さ<5>の文字列
   but: "ScalaTest"の長さは<9>です。

appendValue を使うと、文字列は "" 、int は <> で囲まれます。

TypeSafeDiagnosingMatcher で実装
"diagnose" とは「診断する」という意味の英単語だそうです。 TypeSafeDiagnosingMatcher クラスは TypeSafeMatcher に比べてエラーメッセージを簡単に書けるクラスです。 と言っても TypeSafeMatcher そんなに違いはなく、matchesSafely の引数が T 型(Matcher の型パラメータ)ではなく (T, Description) となっているだけです。 つまり、検証の成否を判定する処理の中でエラーメッセージの構築ができるようになっています。 この引数の Description オブジェクトにメッセージを追加すると、実測値に関するメッセージが構築できます。 期待値に関するメッセージは TypeSafeMatcher と同じく describeTo メソッドで行います。 では TypeSafeDiagnosingMatcher による hasLength の実装:

public class TypeSafeDiagnosingHasLength extends TypeSafeDiagnosingMatcher<String> {

    private final int length;

    public TypeSafeDiagnosingHasLength(int n){
        this.length = n;
    }

    @Override
    protected boolean matchesSafely(String s, Description desc) {
        if(s.length() == this.length) return true;

        desc.appendValue(s);
        desc.appendText("の長さは");
        desc.appendValue(s.length());
        desc.appendText("です。");
        return false;
    }

    @Override
    public void describeTo(Description desc) {
        desc.appendText("長さ");
        desc.appendValue(this.length);
        desc.appendText("の文字列");
    }
}

このサンプルでは TypeSafeMatcher で describeMismatchSafely メソッドに書いてたメッセージを matchesSafely に移しただけになってますが、検証が失敗した原因がいくつか考えられるようなときに、適切なメッセージを構築したりできると思います。 このクラスを使って、以下のようなテスト(上記の実装ではファクトリメソッドを定義してないので、Matcher を普通にコンストラクタでインスタンス化してます)

    @Test
    public void hasLengthをTypeSafeDiagnosingMatcherで実装(){
        Matcher<String> hasLength5 = new TypeSafeDiagnosingHasLength(5);

        assertThat("JUnit", hasLength5);
        assertThat("ScalaTest", hasLength5);
    }

を実行すると "ScalaTest" の行で検証が失敗して

java.lang.AssertionError:
Expected: 長さ<5>の文字列
   but: "ScalaTest"の長さは<9>です。

というエラーメッセージが表示されます。 同じエラーメッセージにしたのでありがたみが薄いですが、こんな感じ。

DiagnosingMatcher は matchesSafely(T, Description) メソッドの代わりに matches(Object, Description) メソッドを実装します。 この場合、引数の Object オブジェクト(実測値)に対して null チェック、型チェック、キャストなどが行われないので、必要ならそれらを自分で行わなければなりません。 DiagnosingMatcher なら、それぞれのステップのどの段階で検証が失敗したかをメッセージに反映することができます。

CustomTypeSafeMatcher で実装
CustomTypeSafeMatcher は無名クラスとして Matcher を定義するためのクラスです。 基本的には使わない方が良いとされています。 matchesSafely メソッドを実装する必要があるのは TypeSafeMatcher クラスと同じですが、describeTo メソッドは実装する必要がありません。 代わりに、コンストラクタに期待値のメッセージを渡します。 実測値のメッセージは概ね実測値のオブジェクトに対して toString を呼び出して得られる文字列です。 では実装。 無名クラスなのでテストコードに直接書いてます:

    @Test
    public void hasLengthをCustomTypeSafeMatcherで実装(){
        Matcher<String> hasLength5 = new CustomTypeSafeMatcher<String>("長さ5の文字列") {
            @Override
            public boolean matchesSafely(String s) {
                return s.length() == 5;
            }
        };

        assertThat("JUnit", hasLength5);
        assertThat("ScalaTest", hasLength5);
    }

実行すると "ScalaTest" の行で検証が失敗して、以下のようなメッセージが表示されます:

java.lang.AssertionError:
Expected: 長さ5の文字列
   but: was "ScalaTest"

実測値のメッセージ「was "ScalaTest"」は CustomTypeSafeMatcher が勝手に作って表示してくれます。 文字列 "ScalaTest" が何文字かといった情報はさすがに出してくれません。

FeatureMatcher で実装
FeatureMatcher は、検証対象オブジェクトを別のオブジェクトに変換して、そのオブジェクトに対して別の Matcher で検証を行います。 Java8 の Stream API でいうと map メソッドで変換して filter で条件を満たすか検証する、って感じでしょうか。 上記の hasLength の例で言うとこんな感じ:

// sutAsStream を sut 1つを要素に持つ Stream として
sutAsStream.map(s -> s.length()).filter(i -> i == 5).findAny();  // まぁ、返り値とかいろいろおかしいが

んー、逆に分かりにくい例えかな? 要は検証対象を別オブジェクトに変換する処理と、その変換後の値の検証が必要だということです。 これらはそれぞれ

  • 検証対象を別オブジェクトに変換する ・・・ FeatureMatcher#featureValueOf(T) : S
  • 変換後の値を検証する ・・・ FeatureMatcher のコンストラクタの第1引数

にて実装します。 featureValueOf メソッドの返り値 S は FeatureMatcher の第2型パラメータで、検証対象オブジェクトの変換後の型です。 また、変換後の値を検証する Matcher は型パラメータとしてこの S 型を持たなければいけません。 HasLength の例で言うと

  • 検証対象を別オブジェクトに変換する ・・・ String#length()
  • 変換後の値を検証する ・・・ CoreMatchers.equalTo Matcher

としてます。 これらを踏まえて、FeatureMatcher を使った HasLength の実装は以下のようになります:

public class FeatureHasLength extends FeatureMatcher<String, Integer> {

    public FeatureHasLength(int n){
        super(CoreMatchers.equalTo(n),
                "a string with length",    // 期待値のメッセージ
                "actual length");           // 実測値のメッセージ(の一部)
    }

    @Override
    protected Integer featureValueOf(String s) {
        return s.length();    // 検証対象オブジェクトの変換
    }
}

describeTo メソッドは実装する必要がありません(英語でいいなら)。 このクラスを使って以下のようなテスト

    @Test
    public void hasLengthをFeatureMatcherで実装(){
        Matcher<String> hasLength5 = new FeatureHasLength(5);

        assertThat("JUnit", hasLength5);
        assertThat("ScalaTest", hasLength5);
    }

を実行すると、"ScalaTest" の行で検証が失敗して、以下のようなメッセージが表示されます:

java.lang.AssertionError:
Expected: a string with length <5>
   but: actual length was <9>

検証対象オブジェクトの変換後の値 <9> は表示してくれますが、変換前の値 "ScalaTest" は表示してくれないもよう。

TypeSafe でない Matcher

ここまでは検証を行うメソッド matchesSafley に渡される実測値オブジェクトが、Matcher<T> の型パラメータ T にキャストされている "TypeSafe" なクラスを見てきました。 多くの場合ではこれらのクラスを使って Matcher を実装すればいいと思いますが、場合によってはそれらのチェックをしてほしくないことがあります。 例えば Set が null 値もしくは空であることを検証したい nullOrEmptySet という Matcher を作ろうとしたとき、CustomTypeSafeMatcher を使って

    @Test
    public void CustomTypeSafeMatcherでは不適合な例(){
        Matcher<Set<?>> nullOrEmptySet = new CustomTypeSafeMatcher<Set<?>>("nullもしくは空集合") {
            @Override
            protected boolean matchesSafely(Set<?> set) {
                return set == null || set.isEmpty();    // null 値もしくは空であることを検証!?
            }
        };

        Set<Number> nullSet = getMaybeNull();
        assertThat(nullSet, is(nullOrEmptySet));  // 検証が失敗する。 不適合!

        Set<Number> eSet = Collections.emptySet();
        assertThat(eSet, is(nullOrEmptySet));

        Set<Number> set = Collections.singleton(1.0);
        assertThat(set, is(not(nullOrEmptySet)));
    }

    <E> E getMaybeNull(){ return null; }

のようにすると、検証対象オブジェクトが null 値なら matchesSafely メソッドが呼ばれる前に検証が失敗するので、思った通りの動作をしてくれません。 こういう場合には "TypeSafe" でない CustomMatcher を使って

    @Test
    public void CustomMatcherで無名クラスのMatcherを作成する(){
        Matcher<Set<?>> nullOrEmptySet = new CustomMatcher<Set<?>>("nullもしくは空集合") {
            @Override
            public boolean matches(Object obj) {
                if (obj == null)
                    return true;

                Set<?> set = (Set<?>)obj;
                return set.isEmpty();
            }
        };

        Set<Number> nullSet = getMaybeNull();
        assertThat(nullSet, is(nullOrEmptySet));

        Set<Number> eSet = Collections.emptySet();
        assertThat(eSet, is(nullOrEmptySet));

        Set<Number> set = Collections.singleton(1.0);
        assertThat(set, is(not(nullOrEmptySet)));
    }

のようにすれば期待通りの動作をしてくれます。 めでたしめでたし。

さて、JUnit / hamcrest の Matcher に関してはこれくらいにして、いい加減『JUnit 実践入門』に戻ろっと。

JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)

JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)