HamcrestのMatcherの作り方

投稿日:  更新日:

新たなMatcherの作り方を紹介します。

HarmcrestのMatcherは充実しています。実装済みのMatcherだけで大体のことは出来てしまいます。

ただ、アプリに特化したMatcherが欲しくなることもあります。Matcherを作成して柔軟に対応できればテストの効率は上がります。

そのような時のために、Matcherの作り方を理解しておきましょう!

スポンサーリンク

Matcherとは

アサーション方式のテストは検証項目の主張を幾つも繰り返していき、全ての主張が正しかったら対象(アプリのこと)を問題なしと判断します。

「AAはBBである」と主張する ⇒正しい
「CCはDDを含む」と主張する ⇒正しい
「EEはFFを持つ」と主張する ⇒正しい ⇒全部「正しい」ならば問題なし
     :
「YYはZZと同じ」と主張する ⇒正しい
 ^^^^^^^^
   検証項目

主張する範囲や内容によって問題あり・なしの基準は変わってきます。それはアプリの開発方針で決まります。

JUnitは元からアサーションの構文を含んでいて、アサーション方式のテストが実施できます。

例えば次のようなものです。

static public void assertTrue(boolean condition)
static public void assertNull(Object object)
static public void assertEquals(Object expected, Object actual)
public static void assertArrayEquals(Object[] expecteds, Object[] actuals)
static public void assertSame(Object expected, Object actual)

この構文だけでもテストは出来るのですが、使い難い点がありました。

assert????で用意される特定のテストしかできない点や、否定といったような他の要素と組み合わせが出来ない点です。また、Object#equal()メソッドに依存しているので、検証内容の範囲が狭い点もあります。

このような使い難い点を改善するため、JUnit 4.4でassertThatが追加されました。

public static <T> void assertThat(T actual, Matcher<? super T> matcher)

例:
num = 100
assertThat(num, is(100))  「numは100である」

この構文で「○○は」と「△△である」の部分が切り離されました。

「△△である」は検証項目が正しい事を判定する判定機です。主張の通り「△△である」と判定されれば主張は正しく、「△△でない」と判定されれば間違いとなります。この判定機がMatcherです。

assetThatの追加で、様々なMatcherを用意すれば、アサーションが柔軟に定義できるようになりました。

様々なMatcherを実装したライブラリとして知られるのがHamcrestです。Hamcrestは「The Hamcrest Project」の成果物ですが、JUnitはHamcrestを含める形で配布されています。HamcrestはJUnitの必須なライブラリなのです。

スポンサーリンク

アサーション(AssetThatとMatcher)の動作

アサーションがどのように動作しているのか理解しましょう。

MatcherAssert.javaとMatcher.javaの記述を見てみます。

public class MatcherAssert {
    public static <T> void assertThat(T actual, Matcher< ? super T> matcher) {
        assertThat("", actual, matcher);
    }
    
    public static <T> void assertThat(String reason, T actual, Matcher< ? super T> matcher) {
        if (!matcher.matches(actual)) {
            正しくなかった時のメッセージ出力
            テスト終了
        }
		テスト継続
    }
    ...
}
public interface Matcher<T> extends SelfDescribing {
    ...
    boolean matches(Object item);
    ...
}

assertThatメソッドは引数のactualをMatcher#matches(actual)へそのまま引き渡します。

matches(actual)の戻り値がtrueならば「検証項目⇒正しい」と判断して、テストを継続します。falseならば「検証項目⇒正しくない」と判断して、テストを終了します。

matches(actual)は、actualを課せられた条件で判定してtrueまたはfalseを返すだけです。

アサーションの結果を決めるのは、matches()の中で行われている判定機の動作であることが分かります。

スポンサーリンク

新規MatcherのベースとなるMatcherは2つ

新規MatcherはベースとなるMatcherを継承(抽象クラスを実装)して作ります。

ベースとなるMatcherは2つあります。

ベースとなるMatcher特徴使用例
BaseMatcheractualのタイプをチェックしない

Matcherインターフェイスを継承しています
is
instanceOf
allOf
anyOf
TypeSafeMatcheractualのタイプをチェックする
actualのNullをチェックをする


BaseMatcherクラスを継承しています
hasItem
hasEntry
startWith
endWith

2つの違いはMatcherの中で「actualのタイプをチェックする・しない」です。

スポンサーリンク

BaseMatcherを継承してMatcher作成

BaseMatcherはactualのタイプをチェックしないので、actualは全てのクラスのオブジェクトを受け付けることになります。その中にはスーパークラスの最上位であるObjectクラスも含まれます。

BaseMatcherの使い所

Matcherは課せられた条件を判定する時にactual(オブジェクト)が持つ機能(メソッド)を使います。

actualは全てのオブジェクトを受け付けるので、必然的にObjectクラスが持つメソッドになってしまいす。なぜなら、全てのオブジェクトに共通のクラス(スーパークラス)だからです。

Objectクラスが持つメソッドは限られています。使えそうなのはObject#equal()くらいでしょうか!

このことから、BaseMatcherの使い所は少ないと思います。

BaseMatcherの継承関係

BaseMatcherの継承関係は下図の通りです。

BaseMatcherの継承関係

matches():必ず実装

課せられた条件を判定する部分です。
「true:断言できる」、「false:断言できない」を返します。

describeTo():必ず実装

テストがfailした時に表示するメッセージを設定します。
期待した内容です。

describeMismatch():必要に応じて変更

テストがfailした時に表示するメッセージを設定します。
実際の内容(またはfailの理由)です。
※デフォルトでactualの値が表示されるようになっています。

Matcherの作成(例:IsMajorityOf、過半数である)

作成例を示します。例は「actualはvalues(可変長引数)の過半数である」を断言します。

KotlinJava
class IsMajorityOf<T>(vararg values: T) : BaseMatcher<T>() {
    private val values: Array<out T>
    init {
        this.values = values
    }

    override fun matches(item: Any): Boolean {
        if (values.size == 0) return false

        var _Count = 0
        for (_Value in values) {
            if (item == _Value) _Count++
        }
        return _Count > values.size / 2
    }

    override fun describeTo(description: Description) {
        description.appendText("<Actual>は")
        description.appendText(Arrays.toString(values))
        description.appendText("の過半数である")
    }

    override fun describeMismatch(item: Any, description: Description) {
        super.describeMismatch(item, description)

        description.appendText("は")
        description.appendText(Arrays.toString(values))
        description.appendText("の過半数でない")
    }
}
    @Test
    fun IsMajority_test() {
        assertThat(1, IsMajorityOf(1, 2, 1))                  // o
        assertThat(1, not(IsMajorityOf(1, 2, 3)))             // o
        assertThat(true, IsMajorityOf(true, true, false))     // o
        assertThat(true, not(IsMajorityOf(false, true, false))) // o
        assertThat("AB", IsMajorityOf("AB", "CD", "AB"))      // o
        assertThat("AB", not(IsMajorityOf("AB", "CD", "EF"))) // o

        assertThat(1, IsMajorityOf(1, 2, 3))                  // x
        assertThat(1, IsMajorityOf())                         // x
        assertThat("AB", IsMajorityOf("AB", "CD", "EF"))      // x(1)
    }
public class IsMajorityOf<T> extends BaseMatcher<T> {

    private T[] mValues;

    public IsMajorityOf_j(T... values) {
        mValues = values;
    }

    @Override
    public boolean matches(Object item) {
        if(mValues.length == 0) return false;

        int _Count = 0;
        for(T _Value: mValues) {
            if(item.equals(_Value)) _Count++;
        }
        return _Count > (mValues.length / 2);
    }

    @Override
    public void describeTo(Description description) {
        description.appendText("<Actual>は");
        description.appendText(Arrays.toString(mValues));
        description.appendText("の過半数である");
    }

    @Override
    public void describeMismatch(Object item, Description description) {
        super.describeMismatch(item, description);

        description.appendText("は");
        description.appendText(Arrays.toString(mValues));
        description.appendText("の過半数でない");
    }
}
    @Test
    public void IsMajority_test() {
        assertThat(1, new IsMajorityOf(1, 2, 1));                 // o
        assertThat(1, not(new IsMajorityOf(1, 2, 3)));            // o
        assertThat(true, new IsMajorityOf(true, true, false));    // o
        assertThat(true, not(new IsMajorityOf(false, true, false)));// o
        assertThat("AB", new IsMajorityOf("AB", "CD", "AB"));     // o
        assertThat("AB", not(new IsMajorityOf("AB", "CD", "EF")));// o

        assertThat(1, new IsMajorityOf(1, 2, 3));                 // x
        assertThat(1, new IsMajorityOf());                        // x
        assertThat("AB", new IsMajorityOf("AB", "CD", "EF"));     // x(1)
    }
java.lang.AssertionError:     ... (1)の結果
Expected: <Actual>は[AB, CD, EF]の過半数である
     but: was "AB"は[AB, CD, EF]の過半数でない

TypeSafeMatcherを継承してMatcher作成

TypeSafeMatcherはactualのタイプをチェックするので、actualは特定のクラスとサブクラスのオブジェクトを受け付けることになります。

TypeSafeMatcherの使い所

Matcherは課せられた条件を判定する時にactual(オブジェクト)が持つ機能(メソッド)を使います。

actualは特定のクラスのオブジェクトを受け付けるので、クラスに特化したメソッドを使うことができます。クラスに特化したMatcherが色々作れるということです。

このことから、TypeSafeMatcherの使い所は多いと思います。

TypeSafeMatcherの継承関係

TypeSafeMatcherの継承関係は下記の通りです。

TypeSafeMatcherの継承関係

matchesSafely():必ず実装

課せられた条件を判定する部分です。
「true:断言できる」、「false:断言できない」を返します。

describeTo():必ず実装

テストがfailした時に表示するメッセージを設定します。
期待した内容です。

describeMismatchSafely():必要に応じて変更

テストがfailした時に表示するメッセージを設定します。
実際の内容(またfailの理由)です。
※デフォルトでactualの値が表示されるようになっています。

青字がタイプをチェックしている部分です。

デフォルトの場合、expectedTypeはmatchesSafelyの引数のタイプ(ジェネリクスで指定されたT)でチェックを行います。そのタイプはコンストラクタの引数で変更が可能になっています。

Matcherの作成(例:IsEvenNumber、偶数である)

作成例を示します。例は「actualは偶数である」を断言します。

KotlinJava
class IsEvenNumber : TypeSafeMatcher<Number>() {

    override fun matchesSafely(item: Number): Boolean {
        return if (item is Byte ||
            item is Short ||
            item is Int ||
            item is Long)
        {
            (item.toLong() % 2) == 0L
        } else {
            false
        }
    }

    override fun describeMismatchSafely(
        item: Number, mismatchDescription: Description) {

        super.describeMismatchSafely(item, mismatchDescription)

        if (item is Byte ||
            item is Short ||
            item is Int ||
            item is Long)
        {
            mismatchDescription.appendText("は奇数である")
        } else {
            mismatchDescription.appendText("は判定できない型(")
            mismatchDescription.appendText(item.javaClass.simpleName)
            mismatchDescription.appendText(")です")
        }
    }

    override fun describeTo(description: Description) {
        description.appendText("<Avtual>は偶数である")
    }
}
    @Test
    fun IsEvenNumber_test() {
        assertThat(4, IsEvenNumber())                         // o
        assertThat(6L, IsEvenNumber())                        // o
        assertThat("8".toShort(), IsEvenNumber())             // o
        assertThat("10".toByte(), IsEvenNumber())             // o
        assertThat("12".toInt(), IsEvenNumber())              // o
        assertThat("14".toLong(), IsEvenNumber())             // o
        
        assertThat(5, IsEvenNumber())                         // x
        assertThat(7L, IsEvenNumber())                        // x(1)
        assertThat("9".toShort(), IsEvenNumber())             // x
        assertThat("11".toByte(), IsEvenNumber())             // x
        assertThat("13".toInt(), IsEvenNumber())              // x
        assertThat("15".toLong(), IsEvenNumber())             // x
        
        assertThat(4.0f, IsEvenNumber())                      // x
        assertThat(5.0f, IsEvenNumber())                      // x
        assertThat(6.0d, IsEvenNumber())                      // x(2)
        assertThat(7.0d, IsEvenNumber())                      // x
        
        assertThat(null, IsEvenNumber())                      // x(3)
    }
public class IsEvenNumber extends TypeSafeMatcher<Number> {

    @Override
    protected boolean matchesSafely(Number item) {
        if(item instanceof Byte ||
                item instanceof Short ||
                item instanceof Integer ||
                item instanceof Long)
        {
            return (item.longValue() % 2) == 0;
        }
        else {
            return false;
        }
    }

    @Override
    protected void describeMismatchSafely(
            Number item, Description mismatchDescription) {

        super.describeMismatchSafely(item, mismatchDescription);

        if(item instanceof Byte ||
                item instanceof Short ||
                item instanceof Integer ||
                item instanceof Long)
        {
            mismatchDescription.appendText("は奇数である");
        }
        else {
            mismatchDescription.appendText("は判定できない型(");
            mismatchDescription.appendText(item.getClass().getSimpleName());
            mismatchDescription.appendText(")です");
        }
    }

    @Override
    public void describeTo(Description description) {
        description.appendText("<Avtual>は偶数である");
    }
}
    @Test
    public void IsEvenNumber_test() {
        assertThat(4, new IsEvenNumber());                        // o
        assertThat(6L, new IsEvenNumber());                       // o
        assertThat(Short.valueOf("8"), new IsEvenNumber());       // o
        assertThat(Byte.valueOf("10"), new IsEvenNumber());       // o
        assertThat(Integer.valueOf("12"), new IsEvenNumber());    // o
        assertThat(Long.valueOf("14"), new IsEvenNumber());       // o

        assertThat(5, new IsEvenNumber());                        // x
        assertThat(7L, new IsEvenNumber());                       // x(1)
        assertThat(Short.valueOf("9"), new IsEvenNumber());       // x
        assertThat(Byte.valueOf("11"), new IsEvenNumber());       // x
        assertThat(Integer.valueOf("13"), new IsEvenNumber());    // x
        assertThat(Long.valueOf("15"), new IsEvenNumber());       // x

        assertThat(4.0f, new IsEvenNumber());                     // x
        assertThat(5.0f, new IsEvenNumber());                     // x
        assertThat(6.0d, new IsEvenNumber());                     // x(2)
        assertThat(7.0d, new IsEvenNumber());                     // x

        assertThat(null, new IsEvenNumber());                     // x(3)
    }
java.lang.AssertionError:     ... (1)の結果
Expected: <Avtual>は偶数である
     but: was <7L>は奇数である

java.lang.AssertionError:         ... (2)の結果
Expected: <Avtual>は偶数である
     but: was <6.0>は判定できない型(Double)です

java.lang.AssertionError:         ... (3)の結果
Expected: <Avtual>は偶数である
     but: was null
スポンサーリンク

ユーティリティクラスへまとめる(Javaの場合)

Matcherを使用する時は毎回newを使ってインスタンスを作成する必要があります。

newを書きたくなければ、Matcherをユーティリティクラスにまとめる方法があります。

HamcrestのMatcher群はMatchersクラスの中にまとめられています。

ユーティリティクラスへまとめると次のようになります。

public class MyMatchers {

    public static <T> Matcher isMajorityOf(T... values) {
        return new IsMajorityOf(values);
    }

    public static Matcher isEvenNumber() {
        return new IsEvenNumber();
    }
}
    @Test
    public void UtilityClass_test(){
        assertThat(1, isMajorityOf(1, 2, 1));                 // T  newが不要
        assertThat(4, isEvenNumber());                        // T  newが不要
    }
スポンサーリンク
スポンサーリンク