JUnitのTheoryテストについて

JUnit 4.4から組み合わせを表現するためのTheoryという機能が導入されています。このTheoryという機能について、日本語での概説がなかったため、調べた範囲で紹介したいとおもいます。

Theoryの実行

Theoryは組み合わせテストのための表現方法です。Theoryを利用するためには、@RunWithアノテーションでTheoriesランナーを指定します。すると、@Theoryアノテーションが指定されたメソッドが実行されます。

@RunWith(Theories.class)
public class HelloTheoryTest {
        @Theory
        public void helloTheory() {
                System.out.println("Hello, Theory!");
        }
}

出力は下記の通りとなります。

Hello, Theory!

DataPointの指定

@DataPointアノテーションを利用すると、@Theoryテストメソッドに引数として渡される値を指定することができます。@DataPointアノテーションは静的変数、もしくは静的メソッドに対して指定します。@DataPoinが複数あれときには、それぞれが@Theoryテストメソッドの引数として渡され、その数だけ@Theoryテストメソッドが実行されることになります。

@RunWith(Theories.class)
public class HelloDataPointTest {

    @DataPoint
    public static String TARO = "Taro";

    @DataPoint
    public static String JIRO = "Jiro";

    @DataPoint
    public static String sabro() {
        return "Saburo";
    }

    @Theory
    public void helloDataPoint(String name) {
        System.out.println("Hello, " + name + "!");
    }
}

出力は下記の通りとなります。

Hello, Taro!
Hello, Jiro!
Hello, Saburo!

@DataPointsアノテーションを使うと、複数の@DataPointを配列によってまとめることができます。

@RunWith(Theories.class)
public class HelloDataPointsTest {

    @DataPoints
    public static String[] NAMES = { "Taro", "Jiro", "Saburo" };

    @Theory
    public void helloDataPoint(String name) {
        System.out.println("Hello, " + name + "!");
    }
}

@Theoryテストメソッドにどのような@DataPointが渡されるかについては、メソッド仮引数の型に基づいて決められます。

@RunWith(Theories.class)
public class TypedDataPoint {

    @DataPoints
    public static int[] INTS = { 1, 2, 3 };
    @DataPoints
    public static String[] STRINGS = { "One", "Two", "Three" };

    @Theory
    public void methodWithInt(int n) {
        System.out.println("#methodWithInt(int) -> " + n);
    }

    @Theory
    public void methodWithString(String s) {
        System.out.println("#methodWithString(String) -> " + s);
    }
}

出力は下記の通りとなります。

#methodWithInt(int) -> 1
#methodWithInt(int) -> 2
#methodWithInt(int) -> 3
#methodWithString(String) -> One
#methodWithString(String) -> Two
#methodWithString(String) -> Three

@TheoryTestメソッドに複数の引数があるとき、@DataPointとして定義された値の全ての組み合わせについて、@TheoryTestメソッドが実行されます。例えば、@DataPointとしてグー、チョキ、パーの3種類が定義されていて、@TheoryTestメソッドにふたつの引数があるとき、9通りの組み合わせが@Theoryテストメソッドの引数として渡され、テストが実行されます。

@RunWith(Theories.class)
public class JankenTest {

    @DataPoints
    public static string[] GESTURES = { "ROCK", "SCISSORS", "PAPER" };

    @Theory
    public void allPair(Gesture lhs, Gesture rhs) throws Exception {
        System.out.printf("%-8s vs %s\n", lhs, rhs);
    }
}

出力は下記の通りとなります。

ROCK     vs ROCK
ROCK     vs SCISSORS
ROCK     vs PAPER
SCISSORS vs ROCK
SCISSORS vs SCISSORS
SCISSORS vs PAPER
PAPER    vs ROCK
PAPER    vs SCISSORS
PAPER    vs PAPER

TheoryとAssume

ここまで、Theoryの基本的な動作について説明をしました。このTheoryを利用して、どのようにテストを行なうかを見ていきましょう。

具体例として、視聴者層の区分を考えます。視聴者層の区分とは、広告代理店などで利用されている区分で、F1層とかM1層とかいう区分です。詳しい説明はWikipediaの項目を読んでください。ここでは視聴者クラスとしえAudienceクラスを定義し、その区分を取得するgetCategory()メソッドを実装します。

enum Gender {
    MALE, FEMALE
}

enum Category {
    C, T, F1, F2, F3, M1, M2, M3
}

class Audience {
    private final Gender gender;
    private final int age;

    public Audience(Gender gender, int age) {
        this.gender = gender;
        this.age = age;
    }

    public Category getCategory() {
        if (age < 13) {
            return Category.C;
        } else if (age < 20) {
            return Category.T;
        } else if (age < 35) {
            return gender == Gender.MALE ? Category.M1 : Category.F1;
        } else if (age < 50) {
            return gender == Gender.MALE ? Category.M2 : Category.F2;
        } else {
            return gender == Gender.MALE ? Category.M3 : Category.F3;
        }
    }
}

このAudienceクラスのTheoryテストを次のように書いてみました。テストデータは同値分割やら限界値分析やらを考慮して、性別のデータと年齢のデータを@DataPointsとして定義します。そして、返却されるカテゴリーがF1層であるかどうかのテストを追加します。

@RunWith(Theories.class)
public class AudienceTest {
    @DataPoints
    public static int[] AGES = { 0, 12, 13, 19, 20, 34, 35, 49, 50, 99 };
    @DataPoints
    public static Gender[] GENDERS = { Gender.MALE, Gender.FEMALE };

    @Theory
    public void categoryIsF1(Gender gender, int age) throws Exception {
        Audience audience = new Audience(gender, age);
        assertThat(audience.getCategory(), is(Category.F1));
    }
}

このテストは失敗します。というのも、性別と年齢との全ての組み合わせが、@Theoryテストメソッドに渡されるのでAssertしているF1層以外のカテゴリーを返すAudienceオブジェクトも生成されるからです。そこで、F1層の組み合わせだけ、Assertされる仕組みが必要になります。

そのために、Assumeを利用します。Assumeは、テストを失敗することなく中止させます。次のソースコードと実行結果を見てください。

@RunWith(Theories.class)
public class HelloAssumeTest {

    @DataPoints
    public static String[] NAMES = { "Taro", "Jiro", "Saburo" };

    @Theory
    public void helloDataPoint(String name) {
        System.out.println("Before: Hello, " + name + "!");
        assumeThat(name.length(), is(6));
        System.out.println("After:  Hello, " + name + "!");
    }
}

出力は下記の通りとなります。

Before: Hello, Taro!
Before: Hello, Jiro!
Before: Hello, Saburo!
After:  Hello, Saburo!

assumeThatの条件が真になった処理だけ、処理が継続していることが分かります。Assertであれば条件が偽になったときにテストが失敗するのに対し、Assumeはテストを失敗させません。Assumeを利用すれば、指定した条件に合致しないときに、それ以降のテスト処理を実行せずに中止させることができます。

AudienceクラスのTheoryテストをAssumeを使って書き直してみましょう。F1層であるかというテストですので、Assumeの条件として女性であること、20歳から35歳の範囲であることを指定します。

@RunWith(Theories.class)
public class AudienceTest {
    @DataPoints
    public static int[] AGES = { 0, 12, 13, 19, 20, 34, 35, 49, 50, 60 };
    @DataPoints
    public static Gender[] GENDERS = { Gender.MALE, Gender.FEMALE };

    @Theory
    public void categoryIsF1(Gender gender, int age) throws Exception {
        assumeThat(gender, is(Gender.FEMALE));
        assumeTrue(19 < age && age < 35);

        Audience audience = new Audience(gender, age);
        assertThat(audience.getCategory(), is(Category.F1));
    }
}

Theoryのメリット、デメリット

Theoryを使うことで、組み合わせ網羅のテストを自動化することができます。JUnitを利用してブラックボックステストを自動化するときには、強力な武器になるでしょう。

一方で、Theoryテストには不便な面もあります。一番不便な点といえば、テストがどのような値のときに失敗したのかを伝えてくれない点です。また、成功したテストケースであっても、全ての成功するケースを網羅できているかを検証できません。成功するテストケースとは別に、失敗するテストケースを成功するテストケースの補集合としてテストするなどの工夫が必要になります。