본문 바로가기
Programming/Spring

@ParameterizedTest

by peter paak 2020. 5. 16.
728x90

최근 클린코드 8기 과정을 들으면서 필요하다고 생각했던 내용을 번역한 글입니다

@ParameterizedTest

  • 메소드는 private, static이 되어서는 안된다
  • 아래 목록 중 하나는 꼭 있어야 한다
    • ArgumentProvider
    • @ValueSource
    • @CsvSource
  • provider는 ArgumentsStream을 제공해야 한다
  • DEFAULT_DISPLAY_NAME
    • "[{index}] {arguments}"
    • {0} : 0번째 argument

정리

@ParameterizedTest

@ValuSource
@NullSource
@EmptySource
@NullAndEmptySource

@EnumSource(Month.class)
@EnumSource(
        value = Month.class, 
        names= {"APRIL",..}, 
        mode = EnumSource.Mode.EXCLUDE
)

@CsvSource({"input,expected"})
@CsvSource(
        value = {"test:test", "tEst:test", "Java:java"}, 
        delimiter = ':'
)

private Stream<Arguments> provide() {
    return Stream.of(Argument.of(),...)
@ParameterizedTest
@ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE})
method(int number) { }
  • 하나의 테스트를 다른 parameter로 여러번 실행 가능

Argument Sources

같은 테스트를 다른 argument로 여러번 실행

1. Simple Values

  • 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)

2. Null & Empty Values

@EmptySource
@NullSource
@NullAndEmptySource

@EmptySource

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

Enum

@EnumSource(Month.class)
@EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
@EnumSource(value = Month.class,
                        names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER", "FEBRUARY"},
                      mode = EnumSource.Mode.EXCLUDE)
@ParameterizedTest
@EnumSource(Month.class) // passing all 12 months
void getValueForAMonth_IsAlwaysBetweenOneAndTwelve(Month month) {
    int monthNumber = month.getValue();
    assertThat(monthNumber >= 1 && monthNumber <= 12);
}
@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));
}

EnumSource.Mode.EXCLUDE

  • names에 해당하는 아이템을 Enum에서 지외
@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));
}

EnumSet

  • Enum들의 Set

EnumSource.Mode.MATCH_ANY

  • name에 Regex가 있을 때

CSV Literals

아래 시나리오의 경우

  • Input 값과 expected 값을 주입
  • 위의 input값으로 actual 결과를 계산
  • actual 값을 expected 값과 비교
@ParameterizedTest
@CsvSource({"test,TEST", "tEst,TEST", "Java,JAVA"})
void toUpperCase_ShouldGenerateTheExpectedUppercaseValue(String input, String expected) {
    String actualValue = input.toUpperCase();
    assertEquals(expected, actualValue);
}
@ParameterizedTest
@CsvSource(value = {"test:test", "tEst:test", "Java:java"}, delimiter = ':')
void toLowerCase_ShouldGenerateTheExpectedLowercaseValue(String input, String expected) {
    String actualValue = input.toLowerCase();
    assertEquals(expected, actualValue);
}

Method

method를 argument source로 사용할 경우

  • method 재활용 할 때 좋겠다
@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)
    );
}
@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, "", "  ");
}

테스트 이름과 메소드 이름이 같을 경우 자동적으로 메소드를 찾는다

class StringsUnitTest {

    @ParameterizedTest
    @MethodSource("com.baeldung.parameterized.StringParams#blankStrings")
    void isBlank_ShouldReturnTrueForNullOrBlankStringsExternalSource(String input) {
        assertTrue(Strings.isBlank(input));
    }
}

public class StringParams {

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

외부 클래스의 메소드를 가져올 수도 있다

  • 패키지.클래스#메소드

Custom Argument Provider

ArgumentsProvider 인터페이스를 상속받은 Custom 클래스의 arguments를 사용

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));
}

🔥Custom Annotation

  • static 변수에서 test argument 제공
    • 하지만, 실제로 Junit 5는 제공하지 않는다
static Stream<Arguments> arguments = Stream.of(
  Arguments.of(null, true), // null strings should be considered blank
  Arguments.of("", true),
  Arguments.of("  ", true),
  Arguments.of("not blank", false)
);

@ParameterizedTest
@VariableSource("arguments")
void isBlank_ShouldReturnTrueForNullOrBlankStringsVariableSource(
  String input, boolean expected) {
    assertEquals(expected, Strings.isBlank(input));
}

하지만, 아래와 같은 방법을 사용하면 가능하다

  1. Annotation 생성
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(VariableArgumentsProvider.class)
public @interface VariableSource {

    /**
     * The name of the static variable
     */
    String value();
}

Then we need to somehow consume the annotation details and provide test argument

  • JUnit5는 2개의 abstraction을 제공한다
    1. AnnotationConsumer — consume the annotation detail
    2. ArgumentProvider — provide test argument
  1. VariableArgumentsProvider 생성

we next need to make the VariableArgumentsProvider class read from the specified static variable and return its value as test arguments:

class VariableArgumentsProvider 
  implements ArgumentsProvider, AnnotationConsumer<VariableSource> {

    private String variableName;

    @Override
    public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
        return context.getTestClass()
                .map(this::getField)
                .map(this::getValue)
                .orElseThrow(() -> 
                  new IllegalArgumentException("Failed to load test arguments"));
    }

    @Override
    public void accept(VariableSource variableSource) {
        variableName = variableSource.value();
    }

    private Field getField(Class<?> clazz) {
        try {
            return clazz.getDeclaredField(variableName);
        } catch (Exception e) {
            return null;
        }
    }

    @SuppressWarnings("unchecked")
    private Stream<Arguments> getValue(Field field) {
        Object value = null;
        try {
            value = field.get(null);
        } catch (Exception ignored) {}

        return value == null ? null : (Stream<Arguments>) value;
    }
}

5. Argument Conversion

5-1. Implicit Conversion

@ParameterizedTest
@CsvSource({"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) // Pssing strings
void someMonths_Are30DaysLongCsv(Month month) {
    final boolean isALeapYear = false;
    assertEquals(30, month.length(isALeapYear));
}
  • UUID
  • Locale
  • LocalDate, LocalTime, LocalDateTime, Year, Month, etc.
  • File and Path
  • URL and URI
  • Enum subclasses

5-1. Explicit Conversion

  • *"yyyy/mm/dd"* → LocalDate
  • ArgumentConverter interface
class SlashyDateConverter implements ArgumentConverter {

    @Override
    public Object convert(Object source, ParameterContext context)
      throws ArgumentConversionException {
        if (!(source instanceof String)) {
            throw new IllegalArgumentException(
              "The argument should be a string: " + source);
        }
        try {
            String[] parts = ((String) source).split("/");
            int year = Integer.parseInt(parts[0]);
            int month = Integer.parseInt(parts[1]);
            int day = Integer.parseInt(parts[2]);

            return LocalDate.of(year, month, day);
        } catch (Exception e) {
            throw new IllegalArgumentException("Failed to convert", e);
        }
    }
}
@ParameterizedTest
@CsvSource({"2018/12/25,2018", "2019/02/11,2019"})
void getYear_ShouldWorkAsExpected(
  @ConvertWith(SlashyDateConverter.class) LocalDate date, int expected) {
    assertEquals(expected, date.getYear());
}

6. Argument Accessor

  • 기본적으로 각 arugmente들은 parameterized test에 제공된다
  • 순차적으로 argument source를 통해 argument가 제공된다
  • 이 때, test method는 매우 더러워 진다
    • 해결법은 모든 argument를 캡슐화하여 ArgumentsAccessor에 제공한다
    • 그리고 argument를 index와 type으로 반환한다
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);
    }
}
  • fullName() 을 테스트하기 위해 , 4개의 argument를 전달한다
    • firstName
    • middleName
    • lastName
    • expected fullName
  • test argument를 반환받기 위해 ArgumentsAccessor를 사용한다
  • 이제 method parameter로 선언하지 않아도 된다
@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());
}
  • 모든 arguments를 ArgumentsAccessor로 캡슐화 하였다
  • 테스트 메소드에서 각 argument들을 index와 함께 반환한다
  • 타입 conversion은 get* 메소드로 제공된다
    • getString(index) retrieves an element at a specific index and converts it to Stringthe same is true for primitive types
    • get(index) simply retrieves an element at a specific index as an Object
    • get(index, type) retrieves an element at a specific index and converts it to the given type

7. Argument Aggregator

  • ArgumentsAccessor abstraction을 직접 사용하는 것은 테스트 코드를 재사용과 가독성을 저해 할 수도 있다
  • 이 문제를 해결하기 위해, custom 그리고 재사용 가능한 aggregator를 만들 수 있다
    • 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));
    }
}
  • @AggregationWith으로 참조한다
@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());
}
  • PersonAggregator는 마지막 3개의 argument들을 받는다
  • 3개의 argument로 Person 인스턴스를 생성한다

8. Customizing Display Names

  • {index} will be replaced with the invocation index – simply put, the invocation index for the first execution is 1, for the second is 2, and so on
  • {arguments} is a placeholder for the complete, comma-separated list of arguments
  • {0}, {1}, ... are placeholders for individual arguments
728x90