りんごとバナナとエンジニア

エンジニア修行の記録

JUnit 5で同じ流れのテストを繰り返すならParameterizedTestを使おう

Javaのテスト環境をJUnit 5にアップデートしてしばらく経った。JUnit 5にはいろいろな新機能があるけれど、中でも便利だと感じたのがParameterizedTestだった。

ParameterizedTestとは

ParameterizedTestを使うと、同じ内容のテストを異なる入力値・出力値で繰り返したい場合に、テストコードを簡潔に書ける。例えば、文字列の長さが偶数かどうかを判定する boolean hasEvenLength(String str) というメソッドをテストするとして、こんな風にテストを書く人もいるかもしれない。

@Test
void testHasEvenLength() {
    String a = "Java";
    String b = "Python";

    assertTrue(hasEvenLength(a));
    assertTrue(hasEvenLength(b));
}

ParameterizedTestを使うと、このように書ける。

@ParameterizedTest
@ValueSource(strings = {"Java", "Python"})
void testHasEvenLength(String str) {
    assertTrue(hasEvenLength(str));
}

@ParameterizedTest というアノテーションをつけることで、str を引数とし、指定した値を一つずつ引数に代入してテストを行うことができる。関数のようなことができると考えればわかりやすい。

ParameterizedTestの利点は、コードが簡潔になるだけではない。 最初の書き方では、 assertEquals が1つでも失敗するとそこでテストが終わり、テスト全体としてFailと表示される。しかしParameterizedTestを使うと、引数に代入した値それぞれの結果が表示される。上記のコードをEclipseで実行した場合、結果は以下のように表示される。

f:id:Udomomo:20190308100526p:plain
通常のTestを使った場合
f:id:Udomomo:20190308100544p:plain
ParameterizedTestを使った場合

このため、 できるだけParameterizedTestを使う習慣をつけることで、テスト失敗箇所とその原因を特定しやすくなる

ParameterizedTestの引数指定の種類

ただし、どんな値でも引数に突っ込めるわけではない。ドキュメントを見ると、ParameterizedTestに入れられる値には制約があり、 @ParameterizedTest と共に指定するアノテーションによって制約が変わる。

https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests

@ValueSource

最もよく使うもの。配列の中にテストに使いたい値を入れておく。ValueSourceで使える値はリテラルのみ であることに注意。また、2つ以上の引数を使うことはできない。

@NullSource, @EmptySource

これを指定すると、それぞれ null と空文字列を引数としてテストできる。2つを合わせた @NullAndEmptySource もある。また、 @ValueSource と組み合わせて使うこともできる。

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"Java", "Python"})
void testHasEvenLength(String str) {
    assertTrue(hasEvenLength(str));
}

@EnumSource

Enumを引数にできる。それ以外のものは引数にできない。特定の変数の取りうる値をEnumで定義しており、それら全ての値で一通りテストケースを実行したいときに重宝する。

@ParameterizedTest
@EnumSource(value = SUITE.class, names = {"SPADE", "CLOVER"})
void testCanDraw(Suite suite) {
    assertTrue(canDraw(suite));
}

@MethodSource

factoryメソッドを引数にできる。streamやcollectionなど、iterableなオブジェクトを返すことで、それぞれの要素を引数としてテストを回せる。

class Card {
  Integer num;

  public Card(int num) {
    this.num = num;
  }
}
public class SampleTest {

  @ParameterizedTest
  @MethodSource("cardProvider")
  void testCardHasNum(Card card) {
    assertNotNull(card.num);
  }

  static Stream<Card> cardProvider() {
    return Stream.of(new Card(1), new Card(2), new Card(3));
  }

}

@CsvSource

CSV形式で引数を指定できる。複数の引数を使ったテストができるので強力だが、CSVの内容をリテラルで書かなければいけないため、Enumなどは解釈してくれない。また、CSVファイルから直接読み込める @CsvFileSource もある。

@ParameterizedTest
@CsvSource({
    "1,1,2",
    "2,4,6",
    "3,7,10"
  })
void testAddNum(int x, int y, int sum) {
  assertEquals(sum, addNum(x, y));
}

(追記: 2019/04/03)
nullや空文字を引数に渡したいときは以下のように書く。

@ParameterizedTest
@CsvSource({
    "hoge,fuga,", //3つ目の引数がnull
    "hoge,fuga,''", //3つ目の引数が空文字
  })

また、@CsvSourceを使うときは、できる限り想定される出力結果も引数に含める方が良い。テスト対象メソッドの入力に使われるものだけを引数にしていると、テスト対象メソッドのロジックに沿ってassertionの中身を条件分岐させることになりがち。しかしこれでは、テスト対象メソッドのロジックに不備があったとき、それに引きずられてテストの条件分岐自体にも誤りが混入し、本来受けるべきテストケースを受けずにテストにパスしてしまう可能性がある。

//よくない例
@ParameterizedTest
@CsvSource({
      "1,1",
      "1,-1",
      "-1,1",
      "-1,-1"
  })
void TestSampleMethod(int firstArg, int secondArg) {
  //この条件分岐が間違っていたら意味がない
  if (firstArg > 0 && secondArg < 0) { 
    assertTrue(sampleMethod(firstArg, secondArg));
  } else {
    assertFalse(sampleMethod(firstArg, secondArg));
  }
}
//よい例
@ParameterizedTest
@CsvSource({
      "1,1,false",
      "1,-1,true",
      "-1,1,false",
      "-1,-1,false"
  })
void TestSampleMethod(int firstArg, int secondArg, boolean expect) {
  //条件分岐が単純になり、テスト対象メソッドからも独立している
  if (expect) {
    assertTrue(sampleMethod(firstArg, secondArg));
  } else {
    assertFalse(sampleMethod(firstArg, secondArg));
  }
}

ParameterizedTestは細分化して使おう

ParameterizedTestは便利だが、引数の制約があるためにうまく書けないこともある。例えば、独自に定義したオブジェクトをテスト対象メソッドの引数として使っている場合、メソッド内でいろいろなプロパティを操作することが多く、まとめてテストしようと思うとなかなか難しい。そのようなときは、 1つのテスト対象メソッドの中で、さらにテストコードを分けることを心がけると良い。引数となっているオブジェクトのプロパティを複数使っていても、テスト対象メソッド内での役割が独立していれば、各プロパティごとにテストコードを分けられる。
テスト対象コードとテストコードは1対1でなければいけないわけではなく、1対2や1対3でも良い。ParameterizedTestは、テストコードをより疎結合にすることを促してくれる。