LoginSignup
1
1

More than 1 year has passed since last update.

JUnit5の@ParameterizedTestで使える各種sourceの使い道考察

Last updated at Posted at 2022-12-02

はじめに

ひとりJUnitアドベントカレンダー2日目の記事です。
今日未明にサッカー日本代表がスペイン代表に白星を上げました。そんな日に投稿しています。

@ParameterizedTestとは

JUnit5から導入された、テストメソッドに対して付与できるアノテーションです。
基本的な使い方は先行研究が山ほどあると思うので割愛しますが、
簡単に言うと、条件や値を引数で与えることによって一つのテストメソッドで複数のケースを実行する仕組みです。

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;

class HogeServiceTest {

  private HogeService hogeService = new HogeService();

  @ParameterizedTest
  @ValueSource(ints = {0, 1, 100, 1000})
  void test(int number) {
    int actual = hogeService.roundDown(number);
    assertThat(actual, is(0));
  }
}

上記のケースでは@ValueSourceで定義した4つの値をテストの引数numberで受け取り、
テスト対象処理hogeService.roundDown(number)に渡しています。

引数を提供する方法は@ValueSource以外にも種類があるため、
本稿ではそのラインナップと、どのようなケースでの利用が適しているかの私見を述べます。
利用場面の考察に比重を置くため、具体的な記述方法の詳細はリファレンス等を参照ください。

ラインナップ

以下の種類があります。

  • @ValueSource
  • @CsvSource
  • @CsvFileSource
  • @EnumSource
  • @MethodSource
  • @ArgumentsSource + @ArgumentsProvider

@ValueSource

冒頭にも例に出した最もシンプルなSourceです。

  @ParameterizedTest
  @ValueSource(ints = {0, 1, 100, 1000})
  void test(int number) {
  }

intの他にも、各種プリミティブ型、StringClassを受け取ることができますが
提供できる引数は1件のみとなります。

引数1件ということは即ち、条件となる値と期待値となる値を同時に渡すことができないため
{(A, α), (B, β)}のような入力ができず、
条件Aに対して期待値α、条件Bに対して期待値βというテストを実現できません。

変動させられるのは条件か期待値のいずれかとなるため、
条件Aに対して期待値α、条件Bに対しても期待値αというような、
条件を変えても期待値が同じとなる処理に対して使うのが適切かと思います。

ValueSourceの例
  @ParameterizedTest
  @ValueSource(strings = {"-", "ー", "–", "―"})
  void ハイフン変換が行われること(String hyphenLikeSymbol) {
    String actual = hogeService.convertHyphen(hyphenLikeSymbol);
    assertThat(actual, is("-"));
  }

  @ParameterizedTest
  @ValueSource(strings = {"~", "〜", "=", "_"})
  void ハイフン変換が行われないこと(String notHyphenLikeSymbol) {
    String actual = hogeService.convertHyphen(notHyphenLikeSymbol);
    assertThat(actual, is(notHyphenLikeSymbol));
  }

上記のような、ハイフン類似記号を半角ハイフンに変換する処理のテストなんてどうでしょう。

当該処理が、ハイフン類似記号なら変換、そうでなければそのままreturnという仕様であれば
処理の戻り値は「半角ハイフン」か「渡された値」の2パターンのみになります。
「ハイフン類似記号の場合」と「そうでない場合」で2種類のテストを書く必要はありますが、
そう分ければ、これは「条件を変えても期待値が同じとなる」処理であると言えましょう。

@ValueSourceは渡される値の視認性が他のsourceより高いことが素敵ポイントです。
どんな条件になるのかが圧倒的一目瞭然ですよね。他のメソッドに飛ばすとかでもないし。
使い所は限られますが、マッチする場面では積極的に使ってよいのではと思います。

ただnullは扱えないのでそちらだけ注意
2022/12/03 追記
扱えました。@NullSource,@EmptySource,@NullAndEmptySourceです。
以下の記述をすると、"~", "〜", "=", "_", null, ""の順番でテストされます。

  @ParameterizedTest
  @ValueSource(strings = {"~", "〜", "=", "_"})
  @NullAndEmptySource
  void ハイフン変換が行われないこと(String notHyphenLikeSymbol) {
    String actual = hogeService.convertHyphen(notHyphenLikeSymbol);
    assertThat(actual, is(notHyphenLikeSymbol));
  }

@CsvSource

カンマ区切りで引数を定義するSourceです。

  @ParameterizedTest
  @CsvSource({"1, 2", "100, 200", "1000, 2000"})
  void test(int number, int expected) {
  }

ダブルクォーテーションで囲まれた固まりがテスト一回分の引数です。
その中で更にカンマ区切りにすることで、第一引数、第二引数・・・を表現します。

  @ParameterizedTest
  @CsvSource({"テスト1の第一引数, テスト1の第二引数", "テスト2の第一引数, テスト2の第二引数"})
  void test(String foo, String bar) {
  }

@ValueSourceとは異なり、複数の引数を渡せるようになったため
条件と期待値を同時に渡すことができ、テスト可能な範囲が大幅に広がりました。

適用対象は、後述の@MethodSourceを使わねばならないほど複雑ではないものの、
引数を複数渡したいがために@ValueSourceでは不足な場合が適切でしょうか。

文字列を暗黙の型変換で引数とするため、複雑なクラスのインスタンスではなく
Stringやプリミティブ型、enumといった単純な値のみで充足することも条件となります。

  @ParameterizedTest
  @CsvSource({"true, true, true", "true, false, false", "false, true, false",
      "false, false, false"})
  void test(boolean isFoo, boolean isBar, boolean expected) {
  }

テスト直上に条件が羅列されているのでテスト内容を把握しやすいですが、
@ValueSourceと比較してしまうと、数の多さも相まって若干複雑になります。

また、テスト1回分の引数をダブルクォーテーションで囲み、文字列として定義する性質上
自動フォーマット等の恩恵が得られず、やろうと思えばいくらでも汚く書けてしまいます。

汚いパターン
  @ParameterizedTest
  @CsvSource({"true,true,foooooooooooobaaaaaaaaaaaar", "true, false,foooooooooooo",
      "false,true, baaaaaaaaaaaar", "false,  false,  null"})
  void test(boolean isFoo, boolean isBar, String expected) {
綺麗なパターン
  @ParameterizedTest
  // @formatter:off
  @CsvSource({
     "true,  true,  foooooooooooobaaaaaaaaaaaar",
     "true,  false, foooooooooooo",
     "false, true,  baaaaaaaaaaaar",
     "false, false, null"
  })
  // @formatter:on
  void test(boolean isFoo, boolean isBar, String expected) {

//@formatter:offは過剰かもしれませんが、
半角スペースでインデントを極力合わせるなど、視認性にも気配りができると嬉しいですね。

@CsvFileSource

引数となる値をcsvファイルで指定するSourceです。

  @ParameterizedTest
  @CsvFileSource(resources = "test.csv")
  void test(String foo, String bar, String expected) {
  }
test.csv
foo1,bar1,expected1
foo2,bar2,expected2
foo3,bar3,expected3

いや〜ちょっと個人的には全く使わないです。
テスト内容を把握するために別ファイルを参照しなければならないのがとてもしんどい。
超大量のケースを処理したいとか、別の用途で作ったcsvがそのまま流用できるとか、
そういう特殊パターンしか使用するタイミングが思いつきませんね。

超大量ケースをやらないといけない時点でテスト設計がおかしい気がしますし、
別の用途で作ったcsvはテストと関係ない理由で内容が変わりそうなので、
どちらの理由もそれなりに厳しさはありそうで、まあやっぱり使わんかな〜〜

@EnumSource

Enumの要素を取得して引数として提供するSourceです。

  @ParameterizedTest
  @EnumSource(Nen.class)
  void test(Nen nen) {
  }

  @AllArgsConstructor
  public enum Nen {

    TEN("纏"),
    ZETSU("絶"),
    REN("練"),
    HATSU("発");

    private String kanji;
  }

突然の四大行はともかく、こちらもなかなか便利です。
上記例のような最低限の指定だとenumの全要素がテストされますが(例では4ケース)、
アノテーションに追加で値を設定することで「特定の要素のみ」「特定の要素以外の全て」などの指定もできます。

こちらは別日の投稿でまた触れる予定ですが、
ロジックの中枢にenumが食い込んでいる場合は採用を検討してみてもよいかと思います。
ただ@ValueSourceと同じく引数を一件のみしか渡せないため、
以下のように、期待結果に応じてテストを分けるような形となるでしょう。

  @ParameterizedTest
  // "ZETSU"以外の全ての定義でテストする設定.
  @EnumSource(value = Nen.class, names = "ZETSU", mode = EnumSource.Mode.EXCLUDE)
  void オーラを使う処理のテスト(Nen nen) {
    Hunter hunter = new Hunter();
    boolean isAuraUsed = hunter.execute(nen);
    assertThat(isAuraUsed, is(true));
  }

  @ParameterizedTest
  // "ZETSU"の定義のみでテストする設定.
  @EnumSource(value = Nen.class, names = "ZETSU", mode = EnumSource.Mode.INCLUDE)
  void オーラを使わない処理のテスト(Nen nen) {
    Hunter hunter = new Hunter();
    boolean isAuraUsed = hunter.execute(nen);
    assertThat(isAuraUsed, is(false));
  }

@MethodSource

大本命。引数が設定されたStreamを返すメソッドを定義するSourceです。

import static org.junit.jupiter.params.provider.Arguments.arguments;

  @ParameterizedTest
  @MethodSource("hogeProvider")
  void test(String foo, String bar, String expected) {
  }

  static Stream<Arguments> hogeProvider() {
    return Stream
        .of(arguments("foo1", "bar1", "expected1"), arguments("foo2", "bar2", "expected2"));
  }

Streamでなくとも、CollectionでもIterableでも配列でも受け取れるようですが
内部的には結局Streamに変換されるということもあってか、公式含めほとんどの例でStreamが使われている印象です。

メソッドを定義してその内部処理で好き放題やれるため、
プリミティブから複雑なインスタンスまで何でもござれ、関数型インターフェースもOKです。
(関数型インターフェースを渡すパターンは別投稿で触れる予定です)

そのため使い道は幅広く、「他のがうまくハマらなかったら使う」くらいの感覚で良いかと思います。
便利だからといって乱用しすぎるとテストクラスがproviderまみれになるので、それはそれで考えものですが・・・

一つ注意したいのは、引数で渡す情報の粒度、可読性です。
例えば、以下のような二つの真偽値を持つクラスを仮定します。

  @AllArgsConstructor
  public class User {
    boolean isFoo;
    boolean isBar;
  }

hogeService.execute()の挙動が、Userの持つ真偽値によって変動する場合
以下のようにisFooisBarの値を引数で渡してテストすることができます。

  @ParameterizedTest
  @MethodSource("hogeProvider")
  void test(boolean isFoo, boolean isBar, String expected) {
    User user = new User(isFoo, isBar);
    String actual = hogeService.execute(user);
    assertThat(actual, is(expected));
  }

  static Stream<Arguments> hogeProvider() {
    return Stream.of(arguments(true, true, "expected1"), arguments(true, false, "expected2"));
  }

テストを読み解く時に肝要なのは、「どんな条件のときにどんな結果になるか」という対応関係だと思っています。
上記の例でprovider側を見るとき、第一引数のtrueが何に相当する真偽値なのかは、
providerを呼び出しているテストの引数を確認しないと判断ができません。

では、以下の書き方だとどうでしょうか。

  @ParameterizedTest
  @MethodSource("hogeProvider")
  void test(User user, String expected) {
    String actual = hogeService.execute(user);
    assertThat(actual, is(expected));
  }

  static Stream<Arguments> hogeProvider() {
    User fooBarUser = new User(true, true);
    User fooUser = new User(true, false);
    return Stream.of(arguments(fooBarUser, "expected1"), arguments(fooUser, "expected2"));
  }

最初の書き方ではproviderから二つの真偽値を受け渡し、テスト側でUserのインスタンスを作成していました。
今回の書き方ではprovider側でインスタンスを作成し、意味のある名前を付けた上でテストに受け渡しています。

// パターン1
Stream.of(arguments(true, true, "expected1"), arguments(true, false, "expected2"));
// パターン2
Stream.of(arguments(fooBarUser, "expected1"), arguments(fooUser, "expected2"));

trueとtrueの時は"expected1"、trueとfalseの時は"expected2"なんだな、よりも
fooBarUserの時は"expected1"fooUserの時は"expected2"なんだな、の方が
人間の脳に優しい読解フローだと思いませんか?

なので、providerが材料をばらばらと渡してテスト側で組み立てるような形よりも、
provider側で条件をしっかり作り上げておいて適切な名前を付けて、
テスト側はその値を用いた処理の実行と検証に専念するくらいの分担の方が綺麗なんじゃないかなあと思います。

優れたテストは仕様書を兼ねると言われますが、逆に難解なテストは本当にしんどいので
検証できてたらOKとかカバレッジ通ってたらOKの精神ではなく、
「どう書いたらテスト内容を簡単に読み取れるか?」という観点は常に持っておいて、
チームに良質なTX(テストエクスペリエンス)を提供できると良いですね。そんな言葉はないが。

@ArgumentsSource + @ArgumentsProvider

@MethodSourceでは満足できない貴方へ・・・的なやつ、という理解。

  @ParameterizedTest
  @ArgumentsSource(HogeProvider.class)
  void test(int number1, int number2, int expected) {
    int actual = hogeService.plus(number1, number2);
    assertThat(actual, is(expected));
  }

  @NoArgsConstructor
  private static class HogeProvider implements ArgumentsProvider {

    @Override
    public Stream<Arguments> provideArguments(ExtensionContext context) {
      return Stream.of(arguments(1, 2, 3), arguments(2, 4, 6), arguments(5, 6, 11));
    }
  }

以下のように、引数としてArgumentsProviderを受け取る書き方もできるようです。

  @ParameterizedTest
  @ArgumentsSource(MogeProvider.class)
  void test(MogeProvider mogeProvider) {
    int actual = hogeService.plus(mogeProvider.number1, mogeProvider.number2);
    assertThat(actual, is(mogeProvider.expected));
  }

  @AllArgsConstructor
  @NoArgsConstructor
  @ToString
  private static class MogeProvider implements ArgumentsProvider {
    private int number1;
    private int number2;
    private int expected;

    @Override
    public Stream<Arguments> provideArguments(ExtensionContext context) {
      return Stream.of(
          arguments(new MogeProvider(1, 2, 3)),
          arguments(new MogeProvider(2, 4, 6)),
          arguments(new MogeProvider(5, 6, 11)));
    }
  }

検証内容は同一ですが、前者と後者では実行時の表示が異なります。
IntelliJでの実行結果は以下の通りです(前者が上、後者が下)。
image.png
引数として受け取ったものを文字列化してテスト名として採用するようなので、
@ToStringのアノテーションが付与されているMogeProviderを使うと、
どの項目がどういう値なのかが綺麗に可視化されます。

こちらの用途ですが、
前述の@MethodSourceが引数提供用のメソッドを作るやり方だったのに対して、
こちらは引数提供用のクラス(ArgumentProviderの実装)を作成する方法です。
メソッドはどうしてもテストクラス等どこかのクラス内に定義しなければなりませんが、
この方法であれば、作成した引数提供クラスを単品で1ファイルとすることができます。

また、provideArguments()が受け取る引数ExtensionContextにはテストの実行情報などが格納されており、
(正直ここでどういう使い方ができるのかはわかりませんが)何かしらのカスタマイズやチューニングができそうな雰囲気を醸し出しています。

よって、複数のテストクラス間で特定の引数バリエーションを共有したい場合や、
より高度なテストが行いたい場合に使用するものと推測されるため、
基本的には他の方法でやりくりし、特殊な場面でのみ引っ張り出すようなものかと思います。
(正直、そういったケースもヘルパークラスや自作のExtensionで対応できそうな気はしますが)

まとめ

  • @ValueSource
    • 引数一つだけ。条件が変わっても期待値が変わらないテストに。
  • @CsvSource
    • 引数複数対応。複雑なクラスを渡したいわけでもなければ大体の場合に使える。
  • @CsvFileSource
    • 引数複数対応。別ファイルからデータ取得。かなり限定的な気がする。
  • @EnumSource
    • 引数一つだけ、しかもenum限定。一部除外などもできて悪くはなさそう。
  • @MethodSource
    • 引数複数対応。万能。providerメソッド地獄にならないよう程々に。
  • @ArgumentsSource + @ArgumentsProvider
    • 引数複数対応。複数テストクラスからの利用や、より高度なカスタマイズが必要な時に。

あくまで私見ですので、よりよい使い方をご存知の方は教えてください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1