[JUnit5] @ParameterizedTest로 한 번에 테스트하자

Jihoon Oh·2022년 2월 15일
4


테스트 코드를 짜다 보면, 한 개의 메소드에 대해서 여러 개의 테스트를 수행해야 하는 경우를 생긴다. 예를 들어, 다음과 같은 User 클래스가 있다고 하자.

public class User {
    private String name;
    
    public User(String name) {
        validateName(name);
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    
    private void validateName(String name) {
        validateNotNull(name);
        validateLength(name);
    }
    
    private void validateNotNull(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("이름은 공백일 수 없습니다.");
        }
    }
    
    private void validateLength(String name) {
        if (name.length > 10) {
            throw new IllegalArgumentException("이름은 10자 이하여야 합니다.");
        }
    }
}

이 User 클래스는 이름이 공백이거나 10자를 넘어가면 IllegalArgumentException을 throw 하도록 하는 규칙을 가지고 있다. 이 검증 과정을 모두 테스트하려면 어떻게 해야 할까?

@Test
@DisplayName("이름으로 null 값 예외처리")
void createUserFromNullName() {
    assertThatThrownBy(() -> new User(null))
            .isInstanceOf(IllegalArgumentException.class);
}

@Test
@DisplayName("공백 이름 예외처리")
void createUserFromBlankName() {
    assertThatThrownBy(() -> new User(""))
            .isInstanceOf(IllegalArgumentException.class);
}

@Test	
@DisplayName("10자를 초과하는 이름 예외처리")
void createUserFromGreaterThan10() {
    assertThatThrownBy(() -> new User("이 이름은 10자를 넘어가는 이름입니다"))
            .isInstanceOf(IllegalArgumentException.class);
}

세 경우 모두 기본 로직 자체는 User 객체를 생성하고, 그 과정에서 IllegalArgumetException을 던지는지를 검증하는 과정인데, 같은 로직임에도 불구하고 테스트가 세 가지나 생겨버렸다.

만약 이름의 규칙이 더 늘어난다면 어떨까? 규칙이 늘어날 때 마다 새로 테스트 메소드를 작성해줘야 할 것이다.

규칙이 많아지는 경우 뿐 아니라 단순히 테스트하고 싶은 값이 많을 경우를 생각해보자.

@Test
@DisplayName("1~10자의 이름으로 자동차 생성")
void createUserTest() {
    assertThat(new User("1"));
    assertThat(new User("12"));
    ...
    assertThat(new User("1234567890");
}

(이런 식으로 극단적으로 테스트 하는 개발자는 없겠지만 예시니 그 부분은 넘어가자.)

assertThat을 10줄이나 작성해야 한다. 이런 코드가 과연 좋은 테스트 코드일 수 있을까?

@ParameterizedTest

JUnit에는 이렇게 여러 개의 테스트를 한번에 작성하기 위한 @ParameterizedTest 라는 어노테이션을 제공한다. 기본적인 사용 방법은 @Test 대신 @ParameterizedTest라는 어노테이션을 사용하는 것 외에는 동일하다.

이 때 파라미터로 넘겨줄 값들을 지정해주어야 하는데, 이 역시 어노테이션을 사용해서 테스트에 주입해줄 수 있다.

@ValueSource

@ParameterizedTest
@DisplayName("ValueSource를 이용한 User 생성 테스트")
@ValueSource(strings = {"", " "})
void createUserException(String text) {
    assertThatThrownBy(() -> new User(text))
            .isInstanceOf(IllegalArgumentException.class);
}

테스트에 주입할 값을 어노테이션에 배열로 지정한다.
테스트를 실행하면 배열을 순회하면서, 테스트 메소드에 인수로 배열에 지정된 값들을 주입해서 테스트한다. 이 때, 하나의 테스트에는 하나의 인수(argumnet)만 전달할 수 있다.
@ValueSource에 사용할 수 있는 자료형은 다음과 같다.

  • byte, short, int, long, float, double, char, boolean
  • String, Class

@CsvSource

@ValueSource 로는 하나의 인수만 전달할 수 있고, 해당 인수에 따른 결과 값을 테스트할 수 없다. 다음의 코드를 보자.

int multiplyBy2(int number) {
    return number * 2;
}

@ParameterizedTest
@DisplayName("1 곱하기 2는 2")
void multiply1By2() {
    assertThat(multiplyBy2(1)).isEqualTo(2);
}

@ParameterizedTest
@DisplayName("2 곱하기 2는 4")
void multiply2By2() {
    assertThat(multiplyBy2(2)).isEqualTo(4);
}

여기서 두 테스트는 모든 로직과 사용하는 메소드가 같다. 단지 테스트하고자 하는 값과 기대하는 결과값이 다를 뿐이다. 이 경우에 위에서 알아본 @ValueSource만으로는 테스트가 불가능하다. 이럴 때 @CsvSource를 사용할 수 있다.

int multiplyBy2(int number) {
    return number * 2;
}

@ParameterizedTest
@CsvSource(value = {"1,2", "2,4", "3,6"})
void multiplyBy2Test(int input, int expected) {
    assertThat(multiplyBy2(input)).isEqualTo(expected);
}

이런 식으로 작성해주면 input에 따라 expected 값이 다르게 나오는 케이스를 여러 개 테스트할 수 있다. value에 값들을 넣어주는데, 이 때 "{input},{expected}" 의 형태로 구분자가 있는 문자열을 입력해주어야 한다. 기본 구분자는 콤마(',')인데, 이는 CSV(Comma Sperated Value)라는 이름을 생각해보면 쉽게 이해할 수 있다.

@ParameterizedTest
@CsvSource(value = {"1:2", "2:4", "3:6"}, delimiter = ':')
void multiplyBy2Test(int input, int expected) {
    assertThat(multiplyBy2(input)).isEqualTo(expected);
}

물론 value 다음에 delimiter 값을 직접 정의해서 넣어줘서 커스텀 구분자를 사용할 수도 있다. 단 이 때 주의할 것은 delimiter 값은 String이 아닌 char 값이기 때문에 반드시 단일 문자를 넣어주어야 한다는 점이다. 만약 String을 넣어주고 싶다면

@ParameterizedTest
@CsvSource(value = {"1//2", "2//4", "3//6"}, delimiterString = "//")
void multiplyBy2Test(int input, int expected) {
    assertThat(multiplyBy2(input)).isEqualTo(expected);
}

와 같이 delimiterString 값을 지정해서 넣어주면 된다.

@NullSource, @EmptySource, @NullAndEmptySource

@ParameterizedTest
@DisplayName("null 값 또는 empty 값으로 User 생성 테스트")
@NullAndEmptySource
void createUserExceptionFromNullOrEmpty(String text) {
    assertThatThrownBy(() -> new User(text))
            .isInstanceOf(IllegalArgumentException.class);
}

@NullSource는 테스트 메소드에 인수로 null을, @EmptySource는 빈 값을, @NullAndEmptySource는 null과 빈 값을 모두 주입한다.

  • 이 때, 원시 값(위의 byte ~ boolean) 에는 null 값이 들어갈 수 없으므로 메소드의 인수가 원시 값이라면 @NullScore, @NullAndEmptySource는 사용이 불가능하다.
  • @NullSource @EmptySource를 모두 사용한 것과 @NullAndEmptySource는 같다.
  • @ValueSource와 같이 사용이 가능하다.
@ParameterizedTest
@DisplayName("null 값 또는 empty 값으로 User 생성 테스트")
@NullAndEmptySource
@ValueSource(strings = {""," "})
void createUserExceptionFromNullOrEmpty(String text) {
    assertThatThrownBy(() -> new User(text))
            .isInstanceOf(IllegalArgumentException.class);
}

@EnumSource

@ParameterizedTest
@DisplayName("6, 7월이 31일까지 있는지 테스트")
@EnumSource(value = Month.class, names = {"JUNE", "JULY"})
void isJuneAndJuly31(Month month) {
    assertThat(month.minLength()).isEqualTo(31);
}

Enum 클래스의 모든 값을 사용하려면 @EnumSource 안에 Enum 클래스만 전달해주면 되고, 특정 값만 필요할 경우 value에 Enum 클래스를 넣어주고, names에 선택할 값의 이름을 전달해주면 된다.
이 때 names까지 값을 넣으면 추가로 mode 값을 넣어줄 수 있다. 지원하는 mode는 다음과 같다.

  • INCLUDE, EXCLUDE, MATCH_ALL, MATCH_ANY

표로 정리하면 다음과 같다.

인수 종류타입설명
valueClass< ? extends Enum<?>>테스트 할 Enum class를 넣는다.
Optional이기 때문에 지정하지 않을 경우
메소드에 인수로 선언된 Enum이 들어간다.
namesStringmode에서 검색에 쓸 문자열 또는 정규식
modeModeINCLUDE: names.contains(name)
(name과 일치하는 모든 Enum 값) // default
EXCLUDE: !names.contains(name))
(name을 제외한 모든 Enum 값)
MATCH_ANY: patterns.stream().anyMatch(name::matches)
(조건을 하나라도 만족하는 Enum 값)
MATCH_ANY:patterns.stream().allMatch(name::matches)
(조건을 모두 만족하는 Enum 값)

@MethodSource

여태까지 위에서 보았던 source들은 분명 유용하지만, 복잡한 object를 전달하는 것이 불가능하다. 이 때 method를 인수로 전달해주면 복잡한 인수를 전달할 수 있다. 백문이 불여일견, 일단 기본 사용 예제를 보고 넘어가자.

@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnTrueForBlankStrings(String input, boolean expected) {
    assertThat(input.isBlank()).isEqualTo(expected);
}

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

provideStringsForIsBlank() 라는 메소드를 정의한 뒤, 이 메소드를 @MethodSource를 이용하여 테스트 메소드에 넘겨주는 기본 예제다. 위 예제를 보면서 @MethodSource의 기본 규칙에 대해 알아보자.

  • @MethodSource에 작성하는 메소드 이름은 인수로 제공하려는 메소드 이름과 같아야 한다.
  • 인수로 제공하려는 메소드는 static이어야 한다.
    • 단, @TestInstance(Lifecycle.PER_CLASS)를 사용하여 클래스 단위 생성주기일 경우 인스턴스 메소드 제공이 가능하다.
  • @MethodSource에 메소드 이름을 작성해주지 않을 경우 JUnit이 테스트 메소드 네임과 같은 메소드를 찾아서 인수로 제공한다.
  • 만약 테스트 호출 당 하나의 인수만 제공하고자 한다면 Arguments로 추상화 할 필요는 없다.
@ParameterizedTest
@MethodSource // 메소드 명을 적지 않았지만 같은 메소드를 찾아서 실행
void isBlank_ShouldReturnTrueForBlankStringsOneArgument(String input) {
    assertThat(input.isBlank()).isTrue();
}

private static Stream<String> isBlank_ShouldReturnTrueForBlankStringsOneArgument() {
    return Stream.of("", "  ");
}
  • argument source method는 Stream<Arguments>를 반환하는 것이 기본이나, List와 같은 컬렉션이나 인터페이스를 반환할 수도 있다.

  • 정규화된 이름(FQN#methodName의 형식)으로 외부 정적 메소드를 참조할 수 있다.

class StringsUnitTest {
    @ParameterizedTest
    @MethodSource("parameterizedtest.StringParams#blankStrings")
    void isBlank_ShouldReturnTrueForBlankStringsExternalSource(String input) {
        assertThat(input.isBlank()).isTrue();
    }
}

// StringParams 클래스는 parameterizedtest 패키지에 있다고 가정
public class StringParams {
    static Stream<String> blankStrings() {
        return Stream.of("", "  ");
    }
}

정리

@ParameterizedTest를 이용하면 여러 개의 테스트 케이스를 사용할 수 있다.

  • @ValueSource: 한 개의 인수 입력 시 사용
  • @CsvSource: 한 개의 인수와 해당 인수를 넣었을 때의 결과값 입력 시 사용
  • @NullSource, @EmptySource, @NullOrEmptySource: null 또는 공백값에 대한 테스트 시 사용
  • @EnumSource: Enum 값에 대한 테스트 시 사용
  • @MethodSource: 테스트에 복잡한 인수를 제공하고자 할 때 메소드를 만들어서 사용


    더 자세한 내용은 링크 참조
profile
Backend Developeer

1개의 댓글

comment-user-thumbnail
2024년 10월 29일

안녕하세요 7기 프리코스 참가생입니다 ㅎㅎ
밑에 이 부분은 혹시 오타인가요?
MATCH_ANY:patterns.stream().allMatch(name::matches)
(조건을 모두 만족하는 Enum 값)
글 정말 잘 보고 갑니다 설명이 아주 잘 되어있어서 많이 배우고 갑니다.

답글 달기