話題の記事

JUnitのカスタムアサーションを簡単に実装できるcmtest

2013.11.20

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

渡辺です。

先日、「JUnitのオブジェクト等価比較を怠けたい!」というスライドが公開されました。「オブジェクトのカスタムアサーションをどのように実現するか」という問題は、ユニットテストを実践していくとよく発生します。この問題に関して、先日のJJUG CCCでも相談されました。また、簡単に書ける仕組みは共有した方が良いのですよね。そんなわけで、cmtestというライブラリにまとめましたので紹介したいと思います。

Objectクラスのequalsメソッド

Javaではオブジェクト同士の比較にはObjectクラスのequalsメソッドを利用することが定石です。これはユニットテストのアサーションでも同様です。テストした結果に作られる実測値と、テストの期待値を比較する時、通常はequalsメソッドを利用します。equalsメソッドを使った比較を行うのであれば、定番のassertThat構文を利用できます。

Item expected = …;
Item actual = …;
assertThat(actual, is(expected));

しかし、なんらかの理由でequalsメソッドをオーバーライドできない場合や、equalseメソッドとは異なる条件で比較検証しなければならない場合などは、equalsメソッドを利用して比較することはできません。ユニットテストでは、このような特殊ケースがしばしば発生します。

例えば、データベースから取得したオブジェクトの比較は最も頻出のパターンです。連番で設定されるシーケンス番号、システム時間に依存してしまうタイムスタンプなどは、ユニットテストの比較では無視したいというケースは誰もが経験したことがあるのではないでしょうか?

本エントリーでは、このようなequalsメソッドに依存しないカスタムアサーションを実装する時の実装方法について紹介します。また、最後に便利なライブラリを紹介したいと思います。

JUnitで独自の比較検証を行う方法

JUnitによるユニットテストでequalsメソッドに依存せず、比較検証する方法は幾つか考えられます。

例として次のようなItemクラスがあるとし、Date型のcreatedAtフィールドを除外して比較検証する方法を考えてみましょう。

public class Item {
    Long id;
    String name;
    Integer price;
    String description;
    Date createdAt;
    @Override
    public String toString() {
        return "Item [id=" + id + ", name=" + name + ", price=" + price
            + ", description=" + description + ", createdAt=" + createdAt
            + "]";
    }
}

このクラスでは、equalsメソッドがオーバーライドされていないため、次のテストは失敗します。

    @Test
    @Ignore("equalsメソッドがオーバーライドされていないため失敗する")
    public void Objectのequalsメソッドを利用する() {
        // Setup
        Item expected = newItem(1L, "test", 2000, "説明", null);
        Item actual = newItem(1L, "test", 2000, "説明", new Date());
        // Exercise
        // - 略 -
        // Verify
        assertThat(actual, is(expected));
    }

    static Item newItem(Long id, String name, Integer price, String desc, Date createdAt) {
        Item obj = new Item();
        obj.id = id;
        obj.name = name;
        obj.price = price;
        obj.description = desc;
        obj.createdAt = createdAt;
        return obj;
    }

equalsメソッドを実装していた場合でも、createdAtを無視したい場合はテストは失敗になります。

個別にアサーションを行う

はじめに紹介する方法は、アサーションをフィールド毎に行うことです。比較したいフィールドをそれぞれ比較します。

    @Test
    public void 個別にアサーションを行う() throws Exception {
        // Setup
        Item expected = newItem(1L, "test", 2000, "説明", null);
        Item actual = newItem(1L, "test", 2000, "説明", new Date());
        // Exercise
        // - 略 -
        // Verify
        assertThat("id", actual.id, is(expected.id));
        assertThat("name", actual.name, is(expected.name));
        assertThat("price", actual.price, is(expected.price));
        assertThat("description", actual.description, is(expected.description));
    }

この方法はJUnit実践入門でも紹介しています。ひとつひとつアサーションを行うため、失敗した原因を特定しやすいことも大きなメリットです。しかし、この方法の問題点は、次のテストを作る段階でコピペが発生することです。そして、比較したいフィールドが増えたり、減ったりした場合にすべてのテストコードを修正することになります。これでは、確実にメンテナンス不能に陥ることでしょう。

DRYじゃないのは許せませんね。

カスタムアサーションをメソッドに抽出する

コピペを防止し、修正の影響を最小限にしたいのであれば、メソッドとして抽出することが最も簡単な解決方法です。

@Test
public void 個別にアサーションを行うメソッドを使う() throws Exception {
    // Setup
    Item expected = newItem(1L, "test", 2000, "説明", null);
    Item actual = newItem(1L, "test", 2000, "説明", new Date());
    // Exercise
    // - 略 -
    // Verify
    verify(actual, expected);
}

static void verify(Item actual, Item expected) {
    assertThat("id", actual.id, is(expected.id));
    assertThat("name", actual.name, is(expected.name));
    assertThat("price", actual.price, is(expected.price));
    assertThat("description", actual.description, is(expected.description));
}

他のテストクラスからも利用できるようにstaticメソッドとして定義するとよいでしょう。次に紹介するカスタムMatcherよりも簡単にできる方法です。多くのケースで現実的な方法だと思います。

カスタムMatcherを作る

JUnitの仕組みを活用するのであれば、カスタムMatcherを作ることができます。

ItemMatcherを作ることで、テストコードは次のように書けるでしょう。コードは長くて読みたくもないのでGistをみてください:p

@Test
public void カスタムMatcherを利用する() throws Exception {
    // Setup
    Item expected = newItem(1L, "tet", 2000, "説明", null);
    Item actual = newItem(1L, "test", 2000, "説明", new Date());
    // Exercise
    // - 略 -
    // Verify
    assertThat(actual, is(item(expected)));
}

この方法はJUnit実践入門でも紹介しました。JUnitの枠組みの中でassertThat構文も活用できるのでテストコードは綺麗になります。コピペによる崩壊もありません。そしてなによりも、アサーションが失敗した時の情報を詳細に通知できることが最大のメリットです。

しかし、各データクラスのカスタムMatcherを作るのは大変です。

オブジェクトのカスタムアサーションを汎用化する

このようにカスタムアサーションが必要な場合、検証メソッドを分離し、そこで個別にアサーションを行うことが現実的な方法です。しかし、この方法では単体のオブジェクトについて検証はできても、リストやマップに対応するにはさらにコードを書く必要があります。

そこで、手軽に実装でき、詳細な情報もレポート可能な、再利用できる仕組みをライブラリとして公開しました。実際に、自分が担当しているJavaのプロジェクトで利用している仕組みです。

ObjectVerifier

ライブラリに含まれるObjectVerifierを使うと、Itemのカスタムアサーションは次のように定義できます。

public class ItemVerifier extends ObjectVerifier<Item> {

    /**
     * Itemオブジェクトの比較検証を行う。
     * @param actual
     * @param expected
     */
    public static void verify(Item actual, Item expected) {
        new ItemVerifier().verifyObject(actual, expected);
    }

    @Override
    public void verifyNotNullObject(Item actual, Item expected) throws AssertionError {
        assertThat("id", actual.id, is(expected.id));
        assertThat("name", actual.name, is(expected.name));
        assertThat("price", actual.price, is(expected.price));
        assertThat("description", actual.description, is(expected.description));
    }

}

使う場合は、staticインポートした上で次のようになります。

@Test
public void 個別にアサーションを行うメソッドを使う() throws Exception {
    // Setup
    Item expected = newItem(1L, "test", 2000, "説明", null);
    Item actual = newItem(1L, "test", 2000, "説明", new Date());
    // Exercise
    // - 略 -
    // Verify
    verify(actual, expected);
}

ObjectVerifierを利用した場合のメリットは、情報量が増えることです。assertThatメソッドにはフィールド名を指定する必要がありますが、フィールド名だけではなくオブジェクト全体のtoString情報もAssertionErrorに乗せてレポートします。

java.lang.AssertionError: Expected: Item [id=1, name=test, price=2000, description=説明, createdAt=null]
Actual: Item [id=1, name=test, price=2000, description=説明_, createdAt=Sun Nov 17 16:33:14 JST 2013]
--
description
Expected: is "説明"
     but: was "説明_"
	at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
	at org.junit.Assert.assertThat(Assert.java:865)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.verifyNotNullObject(ItemVerifier.java:40)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.verifyNotNullObject(ItemVerifier.java:1)
	at jp.classmethod.testing.verifier.ObjectVerifier.verifyObject(ObjectVerifier.java:38)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.verify(ItemVerifier.java:23)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.createdを除外した検証(ItemVerifier.java:49)

非常に細かい点かもしれませんが、ユニットテストは失敗した時にその原因がすぐ解ることは重要です。

IterableVerifier

ObjectVerifierを作成するとListなど反復オブジェクトも同様に検証することができます。この時に使うのが、IterableVerifierです。IterableVerifierには、比較するIterableオブジェクトと、各要素を比較するためのObjectVerifierを指定します。これも使いやすいようにstaticメソッドで定義しましょう。

    /**
     * Itemの反復オブジェクトの比較検証を行う。
     * @param actual
     * @param expected
     */
    public static void verify(Iterable<Item> actual, Iterable<Item> expected) {
        new IterableVerifier<Item>(new ItemVerifier()).verify(actual, expected);
    }

こちらも、テスト失敗時には細かくレポートを作成します。

例えば、サイズが違う場合はこうなります。

java.lang.AssertionError: Size is unmatched.
Expected size: 1
Actual size: 2
Expected: [Item [id=1, name=test1, price=2000, description=説明1, createdAt=Sun Nov 17 16:38:10 JST 2013]]
Actual: [Item [id=1, name=test1, price=2000, description=説明1, createdAt=null], Item [id=2, name=test2, price=2001, description=説明2, createdAt=null]]
	at jp.classmethod.testing.verifier.IterableVerifier.verify(IterableVerifier.java:53)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.verify(ItemVerifier.java:32)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.Iterableオブジェクトを比較する(ItemVerifier.java:59)

2番目の要素のフィールドでアサーションが失敗すればこのようになります。

java.lang.AssertionError: AssertionError at index: 1
Expected: [Item [id=1, name=test1, price=2000, description=説明1, createdAt=Sun Nov 17 16:39:13 JST 2013], Item [id=2, name=test2, price=2001, description=説明2, createdAt=Sun Nov 17 16:39:13 JST 2013]]
Actual: [Item [id=1, name=test1, price=2000, description=説明1, createdAt=null], Item [id=2, name=test2, price=null, description=説明2, createdAt=null]]
--
Expected: Item [id=2, name=test2, price=2001, description=説明2, createdAt=Sun Nov 17 16:39:13 JST 2013]
Actual: Item [id=2, name=test2, price=null, description=説明2, createdAt=null]
--
price
Expected: is <2001>
     but: was null
	at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
	at org.junit.Assert.assertThat(Assert.java:865)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.verifyNotNullObject(ItemVerifier.java:39)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.verifyNotNullObject(ItemVerifier.java:1)
	at jp.classmethod.testing.verifier.ObjectVerifier.verifyObject(ObjectVerifier.java:38)
	at jp.classmethod.testing.verifier.IterableVerifier.verify(IterableVerifier.java:60)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.verify(ItemVerifier.java:32)
	at jp.classmethod.testing.examples.verifier.ItemVerifier.Iterableオブジェクトを比較する(ItemVerifier.java:60)

MapVerifier

マップの値をカスタムアサーションしたい場合は、MapVerifierを使うことができます。

    /**
     * Itemのマップオブジェクトの比較検証を行う。
     * @param actual
     * @param expected
     */
    public static void verify(Map<?, Item> actual, Map<?, Item> expected) {
        new MapVerifier<Item>(new ItemVerifier()).verify(actual, expected);
    }

詳細は、ドキュメントをご覧ください。

IterableVerifier

配列をカスタムアサーションしたい場合は、ArrayVerifierを使うことができます。

    /**
     * Itemの配列の比較検証を行う。
     * @param actual
     * @param expected
     */
    public static void verify(Item[] actual, Item[] expected) {
        new ArrayVerifier<Item>(new ItemVerifier()).verify(actual, expected);
    }

詳細は、ドキュメントをご覧ください。

まとめ

ユニットテストのコードはちょっと気を抜いただけでコピペだらけとなります。早い段階でリファクタリングし、DRYに保つ事が重要です。

しかし、この時になるべく簡単に拡張可能で、詳細なレポートを報告できるようにしましょう。そうすることで、例えJUnitでも可読性の高いテストコードを書く事ができます。

Javaだから綺麗に書けない、Junitだから綺麗に書けないという事はありません。勿論、Groovyの習得コストもユニットテストの習得コストに含められるならば、Groovyを使うことも良い選択肢です。

また、cmtestには色々と機能を増やしていく予定です。こんな機能があると嬉しいなどの要望があれば、どんどんお知らせください。