Parameterized Test in JUnit 5

Nếu các bạn đã thực hiện unit test với JUnit4 có một điểm yếu là không hỗ trợ parameters cho test method dẫn tới việc phải dupl code rất nhiều gây khó khăn cho việc review, update test case 👎. Điểm yếu này có thể khắc phục bằng TestNG. Rất may, JUnit5 ra đời với nhiều cải tiến, trong đó Parameterized Test là cải tiến giúp ta khắc phục được điểm yếu trên ❤️.

JUnit5-logo

Required Setup

Để sử dụng Parameterized Tests ta cần dependency là junit-jupiter-params.

Với Gradle ta thêm:

1
testImplementation('org.junit.jupiter:junit-jupiter-params:5.7.1')

Với Maven ta thêm:

1
2
3
4
5
6
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.7.1</version>
    <scope>test</scope>
</dependency>

Argument Sources

Các tham số của Parameterized Tests được cung cấp bởi các Argument Sources sau:

@ValueSource

Là cách đơn giản nhất để cung cấp data test cho Parameterized Tests. Cách này chỉ hỗ trợ các kiểu literal sau:

  • short
  • byte
  • int
  • long
  • float
  • double
  • char
  • boolean
  • java.lang.String
  • java.lang.Class

Ví dụ sau sẽ test hàm String.isNullOrBlank với 6 trường hợp được liệt kê ở @ValueSource:

1
2
3
4
5
6
7
  @ParameterizedTest(name = "#{index} - String.isNullOrBlank() of [{0}]")
  @EmptySource
  @NullSource
  @ValueSource(strings = ["", " ", "  ", "        ", "\n", "\t"])
  fun `test isNullOrBlank`(str: String?) {
    assertTrue(str.isNullOrBlank())
  }

Và để test 2 trường hợp đặc biệt nullempty ta có thể sử dụng @NullSource@EmptySource hoặc dùng @NullAndEmptySource.

@EnumSource

Để test với data test là enum. Mặc định data test là tất cả các giá trị thuộc enum. Trường hợp chỉ muốn test một vài giá trị trong enum ta có thể làm giống ví dụ.

Ví dụ kiểm tra những tháng 4, 6, 9 và 11 có 30 ngày thuộc enum Month:

1
2
3
4
5
6
  @ParameterizedTest(name = "#{index} - [{0}] have 30 day")
  @EnumSource(value = Month::class, names = ["APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"])
  fun `test month have 30 day`(month: Month) {
    val isALeapYear = false
    assertEquals(30, month.length(isALeapYear))
  }

@MethodSource

@ValueSource@EnumSource chỉ có thể cung cấp các data test đơn giản. Những trường hợp phức tạp ta có thể factory method để cung cấp data test. Một lưu ý là factory method phải là static.

Ví dụ này mình sẽ tối ưu so với bài Use Mock to make Unit Test easy với chỉ một test method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
  @ParameterizedTest(name = "#{0} - {1} -> {2}")
  @MethodSource("provideToTestData")
  fun `test getFullNameCustomerWithTotalBuyPriceGreaterThan`(
    description: String,
    expected: List<String>,
    mockReturn: List<Customer>
  ) {
    val totalBuyPrice = 3_000

    every {
      customerRepository.findByTotalBuyPriceGreaterThan(3_000)
    } returns mockReturn

    assertEquals(
      expected,
      customerService.getFullNameCustomerWithTotalBuyPriceGreaterThan(3_000)
    )

    verify(exactly = 1) { customerRepository.findByTotalBuyPriceGreaterThan(totalBuyPrice) }

  }

và data test sẽ được cung cấp qua:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  companion object {
    @JvmStatic
    fun provideToTestData() =
      Stream.of(
        Arguments.of(
          "Empty list",
          emptyList<String>(),
          emptyList<Customer>()
        ),

        Arguments.of(
          "One customer",
          listOf("Tri Le"),
          listOf(
            Customer(firstName = "Tri", lastName = "Le")
          )
        ),

        Arguments.of(
          "Two customers",
          listOf("Tri Le", "Anna Wesley"),
          listOf(
            Customer(firstName = "Tri", lastName = "Le"),
            Customer(firstName = "Anna", lastName = "Wesley"),
          )
        )
      )
  }

Lưu ý: data trả về của factory method phải là Stream của các Arguments. Tuy nhiên JUnit5 có hỗ trợ các kiểu khác như:

  • DoubleStream
  • LongStream
  • IntStream
  • Collection
  • Iterator
  • Iterable
  • Một mảng của các objects
  • Một mảng của các primitives

@CsvSource

Data test sẽ được cung cấp bởi comma-separated values

Ví dụ sau kiểm tra hàm String.toUpperCase:

1
2
3
4
5
6
  @ParameterizedTest(name = "#{index} - String.toUpperCase() of [{0}]")
  @CsvSource("trile,TRILE", "tRilE,TRILE", "trILE,TRILE")
  fun `test trim`(str: String, expected: String) {
    val actual = str.toUpperCase()
    assertEquals(expected, actual)
  }

@CsvFileSource

Tương tự như @CsvSource nhưng data test được load từ file.

Ví dụ sau kiểm tra hàm String.toUpperCase:

1
2
3
4
5
6
  @ParameterizedTest(name = "#{index} - String.toUpperCase() of [{0}]")
  @CsvFileSource(resources = ["/trim.csv"], numLinesToSkip = 1)
  fun `test trim csv file`(str: String, expected: String) {
    val actual = str.toUpperCase()
    assertEquals(expected, actual)
  }

với data test file:

str,expected
trile,TRILE
tRilE,TRILE
trILE,TRILE

@ArgumentsSource

Cũng tương tự như @MethodSource nhưng data test được cung cấp bởi một implements class của ArgumentsProvider thay vì một factory method. Cá nhân mình thấy @ArgumentsSource không tốt bằng

Ví dụ test input argument khác null:

1
2
3
4
5
  @ParameterizedTest
  @ArgumentsSource(MyArgumentsProvider::class)
  fun testWithArgumentsSource(argument: String?) {
    assertNotNull(argument)
  }

data test được cung cấp bởi:

1
2
3
4
5
6
7
8
9
class MyArgumentsProvider : ArgumentsProvider {
  override fun provideArguments(context: ExtensionContext): Stream<out Arguments> {
    return Stream.of("apple", "banana").map { arguments: String? ->
      Arguments.of(
        arguments
      )
    }
  }
}

Customizing Display Names

Một điểm yếu của Parameterized Tests đó là hiển thị kết quả test không được rõ nghĩa lắm. Ví dụ như:

1
2
3
4
5
6
  @ParameterizedTest
  @EnumSource(value = Month::class, names = ["APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"])
  fun `test month have 30 day`(month: Month) {
    val isALeapYear = false
    assertEquals(30, month.length(isALeapYear))
  }

sẽ trả về kết quả sau (với Gradle test report):

TestMethod nameDurationResult
[1] month=APRILtest month have 30 day(Month)[1]0.033spassed
[2] month=JUNEtest month have 30 day(Month)[2]0.001spassed
[3] month=SEPTEMBERtest month have 30 day(Month)[3]0.001spassed
[4] month=NOVEMBERtest month have 30 day(Month)[4]0.001spassed

nhưng sẽ hơi tối nghĩa nên mình sẽ thay đổi chút như sau:

1
2
3
4
5
6
  @ParameterizedTest(name = "#{index} - [{0}] have 30 day")
  @EnumSource(value = Month::class, names = ["APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"])
  fun `test month have 30 day`(month: Month) {
    val isALeapYear = false
    assertEquals(30, month.length(isALeapYear))
  }

thì kết quả sẽ đẹp hơn:

TestMethod nameDurationResult
#1 - [APRIL] have 30 daytest month have 30 day(Month)[1]0.034spassed
#2 - [JUNE] have 30 daytest month have 30 day(Month)[2]0.001spassed
#3 - [SEPTEMBER] have 30 daytest month have 30 day(Month)[3]0.001spassed
#4 - [NOVEMBER] have 30 daytest month have 30 day(Month)[4]0.001spassed
  • {index} là số thứ tự của các data test.
  • {arguments} danh sách tất cả các argument nameargument value.
  • {0}, {1}, … là argument value theo thứ tự bắt đầu từ 0.
Reference articles
updatedupdated2021-09-192021-09-19
Load Comments?