JUnitにおけるパラメータ化テスト入門

# 概要

JUnitに関わらず、テストコードにおけるフィクスチャのセットアップコードは
長くなりがちであり、可読性を低下させる。

この記事では、テストフィクスチャの要素の中から「入力値」と「期待値」に着目し、
それらの準備をパラメータ化によってスッキリ記述する手法についてまとめる。

# 入力値と期待値のパラメータ化

JUnitのパラメータ化テストに必要なステップは大きく以下の4つである。

1. テストクラスにTheoriesテストランナーを付与する
2. テストケースにTheoryアノテーションを付与し、受け取るパラメータを指定する  
(4.を行わない場合、指定したパラメータの全ての組み合わせが実行される)
3. パラメータを準備する 
4. 実行しないパラメータの組み合わせを定義する

## 1．Theoriesテストランナー

パラメータ化テストを行う場合は、テストクラスのRunWithアノテーションに
org.junit.experimental.theories.Theoriesクラスを指定する。
TeoriesテストランナーはEnclosedテストランナーと併用が可能である。

```java
@RunWith(Enclosed.class)
public class ParameterizedTest {

    @RunWith(Theories.class)
    public static class 数値の場合 {
    }

    @RunWith(Theories.class)
    public static class 文字列の場合 {
    }

    @RunWith(Theories.class)
    public static class 数値と文字列の場合 {
    }

    @RunWith(Theories.class)
    public static class 数値と数値の場合 {
    }
}
```

## 2．Theoryアノテーション

パラメータを使用したいテストメソッドへは、
Testアノテーションの代わりにTheoryアノテーションを付与する。

Theoryアノテーションを付与したテストメソッドは任意の引数を宣言できる。
後述するDataPoint、DataPointsアノテーションが付与されたメンバが
パラメータとして引数にセットされる。

```java
@RunWith(Enclosed.class)
public class ParameterizedTest {

    @RunWith(Theories.class)
    public static class 数値の場合 {
        @Theory
        public void testCase(int num) throws Exception {
        }
    }

    @RunWith(Theories.class)
    public static class 文字列の場合 {
        @Theory
        public void testCase(String str) throws Exception {
        }
    }

    @RunWith(Theories.class)
    public static class 数値と文字列の場合 {
        @Theory
        public void testCase(int num, String str) throws Exception {
        }
    }

    @RunWith(Theories.class)
    public static class 数値と数値の場合 {
        @Theory
        public void testCase(int num1, int num2) throws Exception {
        }
    }
}
```

## 3．パラメータの準備

### DataPointアノテーション

Theoriesテストランナーを使用したパラメータ化テストでは、
DataPointアノテーションを使用してパラメータを定義する。
パラメータはstaticかつpublicなフィールドもしくはメソッドで定義する。

Theoryアノテーションを付与したメソッドの引数には、
DataPointアノテーションが付与された値のうち、型が一致するパラメータの
全ての組み合わせが引数として渡される。

```java
@RunWith(Enclosed.class)
public class ParameterizedTest {

    @RunWith(Theories.class)
    public static class 数値の場合 {
        @DataPoint
        public static int INT_PARAM_1 = 3;
        @DataPoint
        public static int INT_PARAM_2 = 4;
        
        @Theory
        public void testCase(int num) throws Exception {
        	System.out.println("入力値：" + num);
        }
    }

    @RunWith(Theories.class)
    public static class 文字列の場合 {
        @DataPoint
        public static String STR_PARAM_1 = "Hello";
        @DataPoint
        public static String STR_PARAM_2 = "World";
        
        @Theory
        public void testCase(String str) throws Exception {
        	System.out.println("入力値：" + str);
        }
    }

    @RunWith(Theories.class)
    public static class 数値と文字列の場合 {
    	@DataPoint
        public static int INT_PARAM_1 = 3;
        @DataPoint
        public static int INT_PARAM_2 = 4;
        @DataPoint
        public static String STR_PARAM_1 = "Hello";
        @DataPoint
        public static String STR_PARAM_2 = "World";
        
        @Theory
        public void testCase(int num, String str) throws Exception {
        	System.out.println("入力値：" + num + "、" + str);
        }
    }

    @RunWith(Theories.class)
    public static class 数値と数値の場合 {
    	@DataPoint
        public static int INT_PARAM_1 = 3;
        @DataPoint
        public static int INT_PARAM_2 = 4;
        
        @Theory
        public void testCase(int num1, int num2) throws Exception {
        	System.out.println("入力値：" + num1 + "、" + num2);
        }
    }
}
```
ParameterizedTestを実行すると、以下の内容がコンソールに出力される。

```
〜数値の場合〜
　入力値：3
　入力値：4

〜文字列の場合〜
　入力値：Hello
　入力値：World

〜数値と文字列の場合〜
　入力値：3、Hello
　入力値：3、World
　入力値：4、Hello
　入力値：4、World

〜数値と数値の場合〜
　入力値：3、3
　入力値：3、4
　入力値：4、3
　入力値：4、4
```

### フィクスチャオブジェクトの利用

Theoryメソッドに渡す引数が増えてきた場合、
テストクラス内にstaticなDTOを宣言することで、引数を１つにまとめることができる。
引数を保持するDTOをフィクスチャオブジェクトという。

```java
@RunWith(Theories.class)
public class ParameterizedTest {
    @DataPoint
    public static Fixture INT_PARAM_1 = new Fixture(1, 2, 3);
    @DataPoint
    public static Fixture INT_PARAM_2 = new Fixture(0, 2, 2);

    @Theory
    public void testCase(Fixture params) throws Exception {
        assertThat(params.x + params.y, is(params.expected));
    }

    static class Fixture {
        int x;
        int y;
        int expected;

        Fixture(int x, int y, int expected) {
            this.x = x;
            this.y = y;
            this.expected = expected;
        }
    }
}
```

### DataPointsアノテーション

DataPointアノテーションでは１つのパラメータしか定義できなかったが、
DataPointsアノテーションでは複数のパラメータを１箇所で定義することができる。

```java
@RunWith(Theories.class)
public class ParameterizedTest {
    @DataPoints
    public static Fixture[] INT_PARAMS = {
        new Fixture(1, 2, 3),
        new Fixture(0, 2, 2),
    };
    /* DataPointアノテーションを使用した定義
    @DataPoint
    public static Fixture INT_PARAM_1 = new Fixture(1, 2, 3);
    @DataPoint
    public static Fixture INT_PARAM_2 = new Fixture(0, 2, 2);
    */

    @Theory
    public void testCase(Fixture params) throws Exception {
        assertThat(params.x + params.y, is(params.expected));
    }

    static class Fixture {
        int x;
        int y;
        int expected;

        Fixture(int x, int y, int expected) {
            this.x = x;
            this.y = y;
            this.expected = expected;
        }
    }
}
```

### 外部リソース(YAMLファイル)

```yaml:params.yaml
!!seq [
  !!test.ParameterizedTest$Fixture
  { x: 1, y: 2, expected: 3 },
  !!test.ParameterizedTest$Fixture
  { x: 0, y: 2, expected: 2 },
]
```

```java
@RunWith(Theories.class)
public class ParameterizedTest {
    @DataPoints
    public static Fixture[] INT_PARAMS = {
        InputStream in = ParameterizedTest.class
                            .getResourceAsStream("params.yaml");
        return ((List<Fixture>) new Yaml().load(in)).toArray(new Fixture[0]);
    };
    /* DataPointアノテーションを使用した定義
    @DataPoint
    public static Fixture INT_PARAM_1 = new Fixture(1, 2, 3);
    @DataPoint
    public static Fixture INT_PARAM_2 = new Fixture(0, 2, 2);
    */

    @Theory
    public void testCase(Fixture params) throws Exception {
        assertThat(params.x + params.y, is(params.expected));
    }

    static class Fixture {
        int x;
        int y;
        int expected;

        Fixture(int x, int y, int expected) {
            this.x = x;
            this.y = y;
            this.expected = expected;
        }
    }
}
```

## 4．Assumeクラス

パラメータを指定しただけだと、全ての組み合わせのテストが実行される。
そうした場合、組み合わせによってはテストの想定値にそぐわないものも出てくる。

例えば、上記のテストは加算後の値が想定値と同じかを検証していたが、
これが加算後の値が偶数であることを検証するテストになれば
x=1、y=2 のケースは想定にそぐわない入力値となる。

こういったケースでは、Assumeクラスを利用して
テストケースが想定している入力値のみを通すことができる。

Assumeクラスの提供するメソッドにはassumeTrueとassumeThatがあり、
それぞれ以下のように実装する。

```java
assumeTrue(条件式);
assumeThat(入力値, matcherメソッド);
```

動きとしては、どちらもメソッド内の判定がfalseだった時、
つまり、入力値が想定通りではない場合にAssumptionViolatedExceptionを発生させ、
以降の処理を行わない。(テストコード内のreturn句として働く)
この例外はテストランナー内では特別扱いで、例外をキャッチしてもテスト結果は成功となる。

```java
@RunWith(Theories.class)
public class ParameterizedTest {
    @DataPoints
    public static Fixture[] INT_PARAMS = {
        new Fixture(1, 2, 3),
        new Fixture(0, 2, 2),
    };

    @Theory
    public void testCase(Fixture params) throws Exception {
        // 入力値として想定しているのは、加算後の値が偶数となるもの
        // 想定外の入力値(assumeTrue(false)となるもの)の場合、以降の処理は行われない
        assumeTrue((params.x + params.y) % 2 == 0);
        // x + y = 偶数であることを検証
        assertThat((params.x + params.y) % 2 == 0 , is(true));
    }

    static class Fixture {
        int x;
        int y;
        int expected;

        Fixture(int x, int y, int expected) {
            this.x = x;
            this.y = y;
            this.expected = expected;
        }
    }
}
```

# パラメータ化テストの問題

## データの網羅性

テスト品質の一つの指標である網羅性だが、パラメータの指定やAssumeクラスによる
フィルタリングを行うのは実装者であるため、全組み合わせのテストが行われるからといって
要件、仕様を網羅するテストケースとなるわけではない。
よって、テストデータの妥当性や網羅性については、テスト技法に詳しいレビュアーのもと、
検証をするなどの対応が必要となる。

## パラメータに関する情報の不足

Theoriesテストランナーを使用したテストでは、テスト失敗時に表示されるレポートに
「どのパラメータで失敗したのか」という情報が欠落している。
よって、パラメータ化テストではテスト失敗時の調査のしやすさを考慮した対応が必要となる。

```java
/* 例）assertThatメソッドを使用し失敗時のメッセージに入力値を出力する */
@Theory
public void testCase(Fixture params) throws Exception {
    assumeTrue((params.x + params.y) % 2 == 0);
    String failMsg = "Fail when x = " + params.x + ", y = " + params.y;
    assertThat(failMsg, (params.x + params.y) % 2 == 0 , is(true));
}
```
# 参考文献

この記事は以下の情報を参考にして執筆しました。

- [JUnit実践入門 ── 体系的に学ぶユニットテストの技法](https://www.amazon.co.jp/JUnit%E5%AE%9F%E8%B7%B5%E5%85%A5%E9%96%80-%E4%BD%93%E7%B3%BB%E7%9A%84%E3%81%AB%E5%AD%A6%E3%81%B6%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88%E3%83%86%E3%82%B9%E3%83%88%E3%81%AE%E6%8A%80%E6%B3%95-WEB-PRESS-plus-ebook/dp/B07JL78S95)