Parameterized Test란?

테스트 코드 작성시 각 다른 매개변수들로 한 테스트 메소드를 여러 번 실행해야되는 상황이 발생할 수 있다. 이 때 중복 코드를 발생시키지 않고 여러번 테스트를 구현가능 하도록해주는 것이 Parameterized Test 이다.

시작하기

기존의 테스트 코드에서 @ParameterizedTest annotation을 추가하면 사용할 수 있다.

예시:

@ParameterizedTest
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // six numbers
void isOdd_ShouldReturnTrueForOddNumbers(int number) {
    assertTrue(Numbers.isOdd(number));
}

인자 넘기기

테스트 케이스로 사용할 인자를 넘기는 방법에는 여러가지 방법이 있다.

@ValueSource

간단한 값들을 테스트 메소드에 넘길 때 사용할 수 있다.

예시:

@ParameterizedTest
@ValueSource(strings = {"", "  "})
void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

매개 변수로 사용 아래의 자료형만 사용가능하다.

  • short (with the shorts attribute)
  • byte (with the bytes attribute)
  • int (with the ints attribute)
  • long (with the longs attribute)
  • float (with the floats attribute)
  • double (with the doubles attribute)
  • char (with the chars attribute)
  • java.lang.String (with the strings attribute)
  • java.lang.Class (with the classes attribute)

@NullSource / @EmptySource

null 값이나 빈 값을 매개 변수로 사용할 수 있다. StringCollection 에서 사용이 가능하다.

@ParameterizedTest
@NullSource
void isBlank_ShouldReturnTrueForNullInputs(String input) {
    assertTrue(Strings.isBlank(input));
}
@ParameterizedTest
@EmptySource
void isBlank_ShouldReturnTrueForEmptyStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

또한 이 둘을 합쳐 @NullAndEmptySource도 사용이 가능하다.

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"  ", "\t", "\n"})
void isBlank_ShouldReturnTrueForAllTypesOfBlankStrings(String input) {
    assertTrue(Strings.isBlank(input));
}

@Enum

Enumeration에 있는 모든 값을 테스트할 때 사용할 수 있다.

@ParameterizedTest
@EnumSource(Month.class) // passing all 12 months
void getValueForAMonth_IsAlwaysBetweenOneAndTwelve(Month month) {
    int monthNumber = month.getValue();
    assertTrue(monthNumber >= 1 && monthNumber <= 12);
}

또는, names 속성을 이용하여 필요한 값만 테스트를 할 수 있다.

@ParameterizedTest
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}

names 속성 외의 값을 테스트하고 싶다면, mode 속성을 EXCLUDE로 지정하면 된다.

@ParameterizedTest
@EnumSource(
  value = Month.class,
  names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER", "FEBRUARY"},
  mode = EnumSource.Mode.EXCLUDE)
void exceptFourMonths_OthersAre31DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(31, month.length(isALeapYear));
}

정규식을 사용하여 테스트 할 값을 지정할 수도 있다.

@ParameterizedTest
@EnumSource(value = Month.class, names = ".+BER", mode = EnumSource.Mode.MATCH_ANY)
void fourMonths_AreEndingWithBer(Month month) {
    EnumSet<Month> months =
      EnumSet.of(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER, Month.DECEMBER);
    assertTrue(months.contains(month));
}

@CsvSource

입력 값과 예상하는기대 값을 모두 매개 변수로 사용해야되는 경우 CSV파일 형식으로 사용할 수도 있다.

예시:

@ParameterizedTest
@CsvSource({"test,TEST", "tEst,TEST", "Java,JAVA"})
void toUpperCase_ShouldGenerateTheExpectedUppercaseValue(String input, String expected) {
    String actualValue = input.toUpperCase();
    assertEquals(expected, actualValue);
}

코드 상에 CSV을 적는 대신, @CsvFileSource를 통해 CSV 파일을 사용할 수도 있다. 이 Annotation에는 다음의 속성을 가진다.

  • numLinesToSkip: CSV 파일을 읽을 때 넘어갈 라인의 수. 헤더 라인을 넘길 때 유용하게 사용할 수 있다.

  • lineSeparator: 라인 구분 기호를 설정할 수 있다.(default: newline)

  • encoding: 파일 인코딩을 설정할 수 있다.(default: UTF-8)

@ParameterizedTest
@CsvFileSource(resources = "/data.csv", numLinesToSkip = 1)
void toUpperCase_ShouldGenerateTheExpectedUppercaseValueCSVFile(
  String input, String expected) {
    String actualValue = input.toUpperCase();
    assertEquals(expected, actualValue);
}

@MethodSource

메서드를 이용하여 복잡한 인자를 넘길 수 있다.

@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input, boolean expected) {
    assertEquals(expected, Strings.isBlank(input));
}

private static Stream<Arguments> provideStringsForIsBlank() {
    return Stream.of(
      Arguments.of(null, true),
      Arguments.of("", true),
      Arguments.of("  ", true),
      Arguments.of("not blank", false)
    );
}

@MethodSource에 이름을 따로 지정하지 않으면, 테스트 메서드와 같은 이름을 가지는 메서드를 실행한다

@ParameterizedTest
@MethodSource // hmm, no method name ...
void isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument(String input) {
    assertTrue(Strings.isBlank(input));
}

private static Stream<String> isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument() {
    return Stream.of(null, "", "  ");
}

@ArgumentsSource

ArgumentsProvider 인터페이스를 구현하여 인자를 넘기는 방법도 있다.

class BlankStringsArgumentsProvider implements ArgumentsProvider {

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return Stream.of(
          Arguments.of((String) null), 
          Arguments.of(""), 
          Arguments.of("   ") 
        );
    }
}

위와 같이 인터페이스를 구현하였다고 가정한다.

@ParameterizedTest
@ArgumentsSource(BlankStringsArgumentsProvider.class)
void isBlank_ShouldReturnTrueForNullOrBlankStringsArgProvider(String input) {
    assertTrue(Strings.isBlank(input));
}

Argument Accessor

기본적으로 테스트 메서드에 제공되는 각 인자들은 하나의 메서드 매개 변수에 해당한다. 이로인해, 테스트 메서드가 너무 지저분해진다.

ArgumentsAccessor의 인스턴스를 통하여, 메서드의 모든 인수를 캡슐화하여 사용할 수 있다.

class Person {

    String firstName;
    String middleName;
    String lastName;
    
    // constructor

    public String fullName() {
        if (middleName == null || middleName.trim().isEmpty()) {
            return String.format("%s %s", firstName, lastName);
        }

        return String.format("%s %s %s", firstName, middleName, lastName);
    }
}

@ParameterizedTest
@CsvSource({"Isaac,,Newton,Isaac Newton", "Charles,Robert,Darwin,Charles Robert Darwin"})
void fullName_ShouldGenerateTheExpectedFullName(ArgumentsAccessor argumentsAccessor) {
    String firstName = argumentsAccessor.getString(0);
    String middleName = (String) argumentsAccessor.get(1);
    String lastName = argumentsAccessor.get(2, String.class);
    String expectedFullName = argumentsAccessor.getString(3);

    Person person = new Person(firstName, middleName, lastName);
    assertEquals(expectedFullName, person.fullName());
}

ArgumentsAccessor에 접근할 수 있는 메서드는 다음들이 있다.

  • getString(index): index 번째에 위치한 요소를 String으로 바꾼다.
  • get(index): index 번째에 위치한 요소를 Object로 바꾼다.
  • get(index, type): index 번째에 위치한 요소를 type으로 바꾼다.

Argument Aggregator

하지만 Argument Accessor를 사용한 것도 코드의 가독성을 떨어뜨릴 수 있다. 이를 위해 ArgumentsAggregator 인터페이스를 구현한다.

class PersonAggregator implements ArgumentsAggregator {

    @Override
    public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context)
      throws ArgumentsAggregationException {
        return new Person(
          accessor.getString(1), accessor.getString(2), accessor.getString(3));
    }
}

@ParameterizedTest
@CsvSource({"Isaac Newton,Isaac,,Newton", "Charles Robert Darwin,Charles,Robert,Darwin"})
void fullName_ShouldGenerateTheExpectedFullName(
  String expectedFullName,
  @AggregateWith(PersonAggregator.class) Person person) {

    assertEquals(expectedFullName, person.fullName());
}

Display Names 커스터마이징

@ParameterizedTestname 속성을 통해 Display Name을 커스터마이징할 수 있다.

@ParameterizedTest(name = "{index} {0} is 30 days long")
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void someMonths_Are30DaysLong(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}

아래와 같은 결과가 나온다.

├─ someMonths_Are30DaysLong(Month)
│     │  ├─ 1 APRIL is 30 days long
│     │  ├─ 2 JUNE is 30 days long
│     │  ├─ 3 SEPTEMBER is 30 days long
│     │  └─ 4 NOVEMBER is 30 days long
  • {index}: 호출에대한 인덱스
  • {arguments}: 쉼표로 구분 된 전체 인수 목록.
  • {0},{1},...: 개별 인자에 대한 자리 표시자.

관련 사이트

https://www.baeldung.com/parameterized-tests-junit-5

댓글남기기