JUnit5 Jupiter による Parameterized Test の使い方

f:id:Naotsugu:20191115225736p:plain


はじめに

2017年9月11日にリリースされた JUnit 5 ですが、早いものでもう2年が経過しました。

現在も JUnit4 系を利用している現場も多いと思いますが、JUnit5 の @ParameterizedTest を使うためにも早い目に移行することをおすすめします。

JUnit4 系と JUnit5 系は共存することもできますし、JUnit5 のその他の機能は使わずとも、Parameterized Test のためだけにでも導入する価値があると思います。


本記事では、Parameterized Test について、実際のテストで良く使う機能を中心に説明していきます。


JUnit5 の導入

Gradle(5系を想定します) を使う場合、build.gradle は以下のように定義します。

plugins {
    id 'java'
}

repositories {
    jcenter()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.2'

    testImplementation 'org.junit.jupiter:junit-jupiter-params:5.5.2'
    testCompile 'org.assertj:assertj-core:3.14.0'
}

test {
    useJUnitPlatform()
}

testImplementationjunit-jupiter-api を指定し、testRuntimeOnlyjunit-jupiter-engine を指定します。

今回説明する @ParameterizedTest は、上記に加え junit-jupiter-params が必要になりますので、合わせて指定します。

なお、JUnit5 からは Matcher を選択的に導入するようになっています。ここでは、現時点で最も使われているであろう Assertj を導入しました。


Gradle Java 以外の導入については以下のリポジトリのサンプルを参照してください。

github.com


CsvSource による Parameterized Test

Parameterized Test は、テストメソッドへのパラメータを様々な方法で提供することで、異なるパラメータのテストを簡素に記述することができます。

最初に、最もよく使うであろう、パラメータをCSV形式で定義できる @CsvSource から見ていきます。


@CsvSource を使ったテストメソッドは以下のようになります。

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
...
import static org.assertj.core.api.Assertions.*;

class AppTest {

  @ParameterizedTest
  @CsvSource({
    "2019-01-01,  1, 2019-01-02",
    "2019-01-01, 30, 2019-01-31",
    "2019-01-01, 40, 2019-02-10",
  })
  void testWithCsvSource(String baseDateText, int amountToAdd, String expectedText) {

    LocalDate actual   = LocalDate.parse(baseDateText).plusDays(amountToAdd);
    LocalDate expected = LocalDate.parse(expectedText);

    assertThat(actual).isEqualTo(expected);
  }

}

@ParameterizedTest でアノテートし、パラメータを @CsvSource で定義します。


このテストでは、日付の加算をテストしています。

@CsvSource で指定したパラメータを引数にテストメソッドが実行され、以下のような結果が得られます。

f:id:Naotsugu:20191115223638p:plain


パラメータに , を含む場合は、' ' で括ることで1つの項目として扱われます。 foobaz, qux の 2つをパラメータとして渡したい場合は @CsvSource({ "foo, 'baz, qux'" }) のように指定します。

またダブルクオート " をパラメータ値に含ませたい場合は \" のようにエスケープすることができます。


CSVのデリミタである , は、以下のようにして変更することができます。

@CsvSource(value = {
    "2019-01-01 |  1 | 2019-01-02",
    "2019-01-01 | 30 | 2019-01-31",
    "2019-01-01 | 40 | 2019-02-10",
}, delimiter = '|')


パラメータの数が多い場合は @CsvFileSource を使うことで外部CSVからパラメータ生成を行うこともできます。

@CsvFileSource(resources = "/arguments.csv", numLinesToSkip = 1)

大量のパラメータはCSVエディタなどで編集できるとはかどります。


Parameterized Test の型変換

上記例では String と int を引数としていましたが、Parameterized Test では暗黙的な型変換が自動的に行われるため、以下のように書くこともできます。

@ParameterizedTest
@CsvSource(value = {
    "2019-01-01,  1, 2019-01-02",
    "2019-01-01, 30, 2019-01-31",
    "2019-01-01, 40, 2019-02-10",
})
void testWithCsvSource(LocalDate base, int amountToAdd, LocalDate expected) {
    LocalDate actual   = base.plusDays(amountToAdd);
    assertThat(actual).isEqualTo(expected);
}

引数の型を LocalDate に変更しました。これにより @CsvSource で指定した文字列のパラメータが型変換され LocalDate として得ることができます。

Integer や Boolean だったり、File、BigDecimalURIURIjava.time 系については文字列からオブジェクト型へ変換が自動的に行われます。


さらに、引数として指定するクラスに以下のルールを満たすメソッドがあれば、文字列からオブジェクトへの変換が自動的に行われます。

  • ファクトリメソッド: ターゲット型に宣言された非 private の static メソッドで、 String 型の引数を1つだけ受け取り、ターゲット型のインスタンスを返すメソッド(メソッド名は任意)

  • ファクトリコンストラクタ: ターゲット型に宣言された非 private のコンストラクタで、 String 型の引数を1つだけ受け取ってインスタンスを作るコンストラク

双方が合致する場合は ファクトリメソッド が優先されます。


ArgumentsAccessor で引数をまとめて受け取る

引数の数が多く見通しが悪い場合は、ArgumentsAccessor で引数をまとめて受け取ることができます。

@ParameterizedTest
@CsvSource(value = {
    "2019-01-01,  1, 2019-01-02",
    "2019-01-01, 30, 2019-01-31",
    "2019-01-01, 40, 2019-02-10",
})
void testWithCsvSource(ArgumentsAccessor arguments) {

    LocalDate baseDate = arguments.get(0, LocalDate.class);
    int amountToAdd    = arguments.getInteger(1);
    LocalDate expected = arguments.get(2, LocalDate.class);

    LocalDate actual = baseDate.plusDays(amountToAdd);
    assertThat(actual).isEqualTo(expected);
}

ArgumentsAccessor には getInteger() などのプリミティブ型を取得するメソッドが提供されており、引数のインデックスを指定して引数を取得できます。

その他のオブジェクト型の場合は引数でターゲットタイプを指定することでキャスト無しで値を取得できます。


ArgumentConverter で任意のオブジェクトを受け取る

暗黙的な型変換が行えないクラスを扱いたい場合は、コンバータを定義することで恣意的な型変換を行うことができます。

以下のようなコンバータを定義します。

public static class YmdArgumentConverter extends SimpleArgumentConverter {

    DateTimeFormatter ymd = DateTimeFormatter.ofPattern("yyyy/MM/dd");

    @Override
    public Object convert(Object input, Class<?> targetClass)
           throws ArgumentConversionException {

        if (!LocalDate.class.isAssignableFrom(targetClass)) {
            throw new ArgumentConversionException(
                "Cannot convert to " + targetClass.getName() + ": " + input);
        }
        return LocalDate.parse(input.toString(), ymd);
    }

}

日付文字列を "yyyy/MM/dd" 形式で変換できるコンバータを作成しました。


@ConvertWith アノテーションで引数にコンバータを指定します。

@ParameterizedTest
@CsvSource(value = {
    "2019/01/01,  1, 2019/01/02",
    "2019/01/01, 30, 2019/01/31",
    "2019/01/01, 40, 2019/02/10",
})
void testWithCsvSource(
        @ConvertWith(YmdArgumentConverter.class) LocalDate base,
        int amountToAdd,
        @ConvertWith(YmdArgumentConverter.class) LocalDate expected) {

    LocalDate actual   = base.plusDays(amountToAdd);
    assertThat(actual).isEqualTo(expected);

}

これにより、任意のクラスの型変換を行うことができます。

プロジェクトで用意した Value Object などはコンバータを用意しておくと便利でしょう。


ArgumentsAggregator で複雑なインスタンスを扱う

ここまでのテストケースは引数の数も少なく単純なものでした。

@AggregateWith を使うことで、実プロジェクトで扱うような大きなインスタンスを引数で受けることができます。

ArgumentsAggregator の実装クラスを以下のように定義します。

public class PersonAggregator implements ArgumentsAggregator {
    @Override
    public Person aggregateArguments(
            ArgumentsAccessor arguments, ParameterContext context) {
        return new Person(arguments.getString(0),
                          arguments.getString(1),
                          arguments.get(2, Gender.class),
                          arguments.get(3, LocalDate.class));
    }
}

ここでは Person オブジェクトを生成するものとして定義しています。


テストメソッド側では @AggregateWith で Aggregator を指定します。

@ParameterizedTest
@CsvSource({
    "Jane, Doe, F, 1990-05-20",
    "John, Doe, M, 1990-10-22"
})
void testWithArgumentsAggregator(@AggregateWith(PersonAggregator.class) Person person) {
    ...
}

これにより様々な特性を持った Person オブジェクトに対するテストが行えるようになります。


Aggregator を複数用意すれば、以下のように複数のオブジェクトを取得することもできます。

@ParameterizedTest
@CsvSource({
        "Jane, Doe, F, 1990-05-20, book1, 1200",
        "John, Doe, M, 1990-10-22, book2, 980"
})
void testWithArgumentsAggregator(
        @AggregateWith(PersonAggregator.class) Person person,
        @AggregateWith(BookAggregator.class) Book book) {


MethodSource でプログラマティックにパラメータを生成する

今まではアノテーション内に定義した CSV でパラメータを指定していましたが、@MethodSource にてパラメータをプログラマティックに生成することができます。

@ParameterizedTest
@MethodSource("methodSourceProvider")
void testWithMethodSource(String actualText, int amountToAdd, String expectedText) {
    ...
}

static Stream<Arguments> methodSourceProvider() {
    return Stream.of(
            arguments("2019/01/01",  1, "2019/01/02"),
            arguments("2019/01/01", 30, "2019/01/31"),
            arguments("2019/01/01", 40, "2019/02/10"));
}

Stream<Arguments> を返す static メソッドを用意し、@MethodSource("methodSourceProvider") として指定することで、メソッドで生成した値を引数に与えることがでます。


@MethodSource の引数を省略した場合、テストメソッドと同じ名前をもつファクトリメソッドを自動的に探します。

つまり以下のようになります。

@ParameterizedTest
@MethodSource
void testWithMethodSource(String actualText, int amountToAdd, String expectedText) {
    ...
}

static Stream<Arguments> testWithMethodSource() {
    ...
}


引数のプロバイダは別クラスに定義することもできます。

package example;

class PersonProviders {

    static Stream<Arguments> personProvider() {
        return Stream.of(
            new Person("Jane", "Doe", F, LocalDate.parse("1990-05-20")),
            new Person("John", "Doe", M, LocalDate.parse("1990-10-22")));
    }
}

先ほどの例では、arguments としてパラメータ生成をしていましたが、このように直接オブジェクトを生成することもできます。


@MethodSource では以下のように完全修飾した形でプロバイダを指定します。

@ParameterizedTest
@MethodSource("example.PersonProviders#personProvider")
void testWithMethodSource(Person person) {
    ...
}


その他の話題


ParameterizedTest の表示

Parameterized Test は以下のようにすることで、テスト結果の表示をカスタマイズすることができます。

@DisplayName("LocalDate plusDays")
@ParameterizedTest(name = "{index} ==> base=''{0}'', amountToAdd={1}, expected=''{2}''")
@CsvSource(value = {
        "2019-01-01 |  1 | 2019-01-02",
        "2019-01-01 | 30 | 2019-01-31",
        "2019-01-01 | 40 | 2019-02-10",
}, delimiter = '|')
void testWithCsvSource(ArgumentsAccessor arguments) {
    ...
}

'{0}' とすると {0} という文字列が出力されるため、' を出力するには ''{0}'' のように指定します。

以下のようなテスト結果が得られます。

f:id:Naotsugu:20191115223702p:plain


単項目

実際のテストではあまり使いませんが、@ValueSource にて単項目のパラメータ生成を行うことができます。

@ParameterizedTest
@ValueSource(ints = { 1, 2, 3 })
void testWithValueSource(int argument) {
    assertThat(argument).isBetween(1, 3);
}

プリミティブ型や文字列の生成が行えます。


まとめ

JUnit5 における Parameterized Test の使い方について説明しました。

各種アノテーションによる様々なパラメータ生成方法があることが分かりました。

  • @CsvSourceCSV形式の文字列からパラメータを生成
  • @CsvFileSourceCSVファイルからパラメータを生成
  • @ConvertWith : ArgumentConverter でパラメータの型変換を定義
  • @AggregateWith : Aggregator でパラメータを集約してオブジェクトを生成
  • @MethodSource : メソッドでパラメータを生成

旧来ではパラメータ値を変化させたテストが書きにくかったですが、JUnit5 の Parameterized Test 機能を使うことで、簡単に可読性の高いテストを書くことができます。




テスト駆動開発

テスト駆動開発

実践 JUnit ―達人プログラマーのユニットテスト技法

実践 JUnit ―達人プログラマーのユニットテスト技法

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

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