倭マン's BLOG

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

ご注文は hamcrest-library ですか? ~TypeSafeMatcher で作るカスタム Matcher 編~

前回は hamcrest-library (バージョン1.3) に定義されている Matcher をザッと見てみましたが、今回は独自の Matcher (カスタム Matcher)を実装する方法を見ていきます。 よく考えると(別に考えなくても)カスタム Matcher は JUnit のみで作成できて hamcrest-library は必要ないので記事のタイトルはミスリーディングだけど、まぁしかたないのでそのまま逝きます。

はじめに

『JUnit 実践入門』第4章4節で日付の比較検証を行うカスタム Matcher の作成方法が書かれてますが、ちょっと実装方法がよろしくなさそうなので(JUnit, hamcrest のバージョンの違いかも知れませんが)、ここで再実装してみます。 どこがよろしくなさそうかというと、検証対象のオブジェクトをフィールドとして保持しているところです。 これは検証失敗時に原因がわかるメッセージを出力するためとのことですが、BaseMatcher ではない他のクラスを拡張してカスタム Matcher を作ればこれを回避できます*1

java.util.Date を使うと比較や文字列への変換などの本筋とは関係ない処理で全体像が読み取りにくくなるので、ここでは新しい日時 API に含まれている java.time.LocalDate クラスをベースにしてコードを書くことにします。

TypeSafeMatcher で実装

カスタム Matcher を作る際には、多くの場合 org.hamcrest.TypeSafeMatcher クラスを拡張して作るのが一番簡単だと思います。 他にもいろいろベースにできる Matcher の抽象クラスがありますが、それらは次回以降で。 ここで作る IsDate クラスも TypeSefeMatcher を拡張して作ります。 TypeSafeMatcher<T> では少なくとも次の2つのメソッドを実装する必要があります:

  • matchesSafely(T actual) : boolean
  • describeTo(Description desc) : void

describeTo メソッドは BaseMatcher と同じで期待値を引数の Description オブジェクトに書き込みます。 一方、matchesSafely は BaseMatcher の matches メソッドに対応するメソッドで、検証のロジックを実装するメソッドですが、matches の引数が Object 型であるのに対して、TypeSafeMatcher の matchesSafely の引数は TypeSafeMatcher<T> の型パラメータの T 型になっています。 これは、TypeSafeMatcher の方で

  • null チェック(null ならマッチしない)
  • 型チェック(型パラメータの型のインスタンスでなければマッチしない)
  • キャスト

という処理をやってくれるためです(よって、null の場合にマッチするような Matcher を作りたい場合などでは使用不可)。 なので、検証のロジックを書くのが簡単になります。 これを踏まえて IsDate を実装するとこんな感じになります:

import java.time.LocalDate;
import java.time.Month;

import org.hamcrest.Matcher;
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;
import org.hamcrest.Factory;

public class IsDate extends TypeSafeMatcher<LocalDate>{

    private final LocalDate date;

    private IsDate(LocalDate date){
        this.date = date;
    }

    @Override
    protected boolean matchesSafely(LocalDate localDate) {
        return localDate.equals(this.date);
    }

    @Override
    public void describeTo(Description desc) {
        desc.appendValue(this.date);
    }

    @Factory
    public static Matcher<LocalDate> dateOf(int year, int month, int day){
        return dateOf(year, Month.of(month), day);
    }

    @Factory
    public static Matcher<LocalDate> dateOf(int year, Month month, int day){
        return dateOf(LocalDate.of(year, month, day));
    }

    @Factory
    public static Matcher<LocalDate> dateOf(LocalDate date){
        if(date == null)
            throw new NullPointerException("IsDate#dateOfの引数はnullであってはいけません。");

        return new IsDate(date);
    }
}

Matcher オブジェクトを生成するファクトリメソッドには @Factory アノテーション(org.hamcrest パッケージ内にある)を付けておくようになったようです(別になくてもかまいませんが)。 LocalDate の equals や toString (Description#appendValue で呼ばれる)を使うと楽すぎる! この Matcher を使って以下のようなテスト

import static org.hamcrest.CoreMatchers.is;
import static java.time.Month.FEBRUARY;
// import 文、一部省略。

public class IsDateTest {

    @Test
    public void 日付を比較する(){
        LocalDate date = LocalDate.now();
        assertThat(date, is(dateOf(2011, FEBRUARY, 10)));
            // 別に is はいらないけど。 そして、FEBRUARY は 2 で OK。
    }
}

を実行してみると、(本日が2011年2月10日でないなら)検証が失敗して、以下のようなエラーメッセージが表示されます(2015年7月3日現在):

java.lang.AssertionError:
Expected: is <2011-02-10>
  but: was <2015-07-03>

上 (Expected) が期待値、下が実測値ですね。 期待値の箇所の "is" は is Matcher が出力しています。

エラーメッセージの実測値をカスタマイズする

さて、IsDate については別にこれでいいんですが、上記の実装では describeTo メソッドを変えて期待値の表示形式は変更することはできても、実測値の表示形式は変えられません。 実際には実測値に関してもっとエラーメッセージを出力させたい場合があると思うので、以降で実測値の表示形式を変更する方法を見ていきます。

とは言ったものの、そんなに大して難しくなく、上記2つのメソッド以外に

  • describeMismatchSafely(T actual, Description desc) : void

を実装すればいいだけです。 第1引数は実測値で、『JUnit 実践入門』のサンプルでフィールドとして持たせていたオブジェクトです。 第2引数の Description オブジェクトは describeTo メソッドの引数の Description のようにメッセージを追加していくオブジェクトです(describeTo メソッドに渡されるものと同じオブジェクトかどうかは知りません)。 これを踏まえて、上記の IsDate クラスを書き換えてみましょう。 期待値、実測値ともに日付の出力を <2o15/7/3> のような形式にします:

import org.hamcrest.Matcher;
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;
import org.hamcrest.Factory;

import java.time.LocalDate;
import java.time.Month;
import java.time.format.DateTimeFormatter;

public class IsDate extends TypeSafeMatcher<LocalDate>{

    private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("<yyyy/M/d>");

    private final LocalDate date;

    private IsDate(LocalDate date){
        this.date = date;
    }

    @Override
    protected boolean matchesSafely(LocalDate localDate) {
        return localDate.equals(this.date);
    }

    @Override
    public void describeTo(Description desc) {
        desc.appendText(this.date.format(FORMAT));
    }

    @Override
    protected void describeMismatchSafely(LocalDate actual, Description desc) {
        desc.appendText("was " + actual.format(FORMAT));
    }

    // 各種ファクトリメソッド
}

ファクトリメソッドは先ほどと同じなので省略。 LocalDate#format メソッドで日付をフォーマットしています。 フォーマットは DateTimeFormatter#ofPattern で生成してます*2。 これを使ってテストを実行すると

java.lang.AssertionError:
Expected: is <2011/2/10>
  but: was <2015/7/3>

となり、確かに日付のフォーマットが変わっています。 実測値の "was " も describeMisMatchSafely で書き出していることに注意。 describeMismatchSafely メソッド内で Description オブジェクトにテキストを追加していけば、好きなメッセージ(もちろん日本語も)を追加できます。

さいごに

今回は TypeSafeMatcher を使ってカスタム Matcher を作成する方法を見てきました。 org.hamcrest パッケージ(hamcrest-library なしで OK)には、他にもカスタム Matcher を作るのに便利なクラスが定義されているので、次回はそれらを簡単に見ていきたいと思います。 そしていい加減「ScalaTest も 『JUnit 実践入門』もまとめて相手してやんよ」に戻りたいと思います・・・ 戻ったら今度は ScalaTest の Matcher なのだけど。

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

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

*1:普通は Matcher オブジェクトを共有することはないと思うので検証対象のオブジェクトを保持していても大して問題は起こさないと思いますが、何となく気持ち悪いですな。

*2:M や d が一文字だと7月が「07」ではなく「7」と表示されます。 12月はきちんと「12」と2文字で表示されます。