[JUnit5] 여러 값으로 단위 테스트 반복하기(@ParameterizedTest, @ValueSource, @CsvSource, ArgumentsAggregator , ArgumentsAggregator )

안녕하세요. 오늘은 여러가지 파라미터값으로 하나의 단위테스트를 반복적으로 실행하여 하나의 단위테스트라도 여러 파라미터값으로 복합적으로 테스트해볼 수 있는 내용을 포스팅해보고자 합니다.

 

사전준비

pom.xml에서 어떤 의존성을 사용하실지 헷갈리실 분들을 위해 pom.xml 을 보여드리도록 하겠습니다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.0.RELEASE</version>
        <relativePath/>
    </parent>
    <groupId>woo.sunghwan</groupId>
    <artifactId>inflean-the-java-test</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>inflean-the-java-test</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <!-- Spring Boot 2.2 부터 JUnit5를 탑재하였다. -->
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
            <version>${parent.version}</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

 

먼저 테스트 예제를 소개하기 전 보아야할 어노테이션에 대해서 알아보고자 합니다.

사용 어노테이션들

1. @ParameterizedTest()

@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(status = EXPERIMENTAL, since = "5.0")
@TestTemplate
@ExtendWith(ParameterizedTestExtension.class)
public @interface ParameterizedTest {

	@API(status = EXPERIMENTAL, since = "5.3")
	String DISPLAY_NAME_PLACEHOLDER = "{displayName}";

	@API(status = EXPERIMENTAL, since = "5.3")
	String INDEX_PLACEHOLDER = "{index}";

	@API(status = EXPERIMENTAL, since = "5.3")
	String ARGUMENTS_PLACEHOLDER = "{arguments}";

	@API(status = EXPERIMENTAL, since = "5.6")
	String ARGUMENTS_WITH_NAMES_PLACEHOLDER = "{argumentsWithNames}";

	@API(status = EXPERIMENTAL, since = "5.3")
	String DEFAULT_DISPLAY_NAME = "[" + INDEX_PLACEHOLDER + "] " + ARGUMENTS_WITH_NAMES_PLACEHOLDER;
	String name() default DEFAULT_DISPLAY_NAME;

}

- 파라미터를 여러번 던져서 테스트를 수행할 수 있도록 하는 어노테이션

- 반드시 파라미터를 넘겨야하며 @ValueSource 혹은 @CsvSource 등을 붙여서 파라미터를 같이 던져주어야한다.

- 각각의 테스트_1, 테스트_2 에 해당하는 이름을 붙일 수 있도록 {displayName}_{index} 와 같이 사용할 수 있다.

 

2. @ValueSource

@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(status = EXPERIMENTAL, since = "5.0")
@ArgumentsSource(ValueArgumentsProvider.class)
public @interface ValueSource {

	short[] shorts() default {};
	byte[] bytes() default {};
	int[] ints() default {};
	long[] longs() default {};
	float[] floats() default {};
	double[] doubles() default {};
	char[] chars() default {};
	boolean[] booleans() default {};
	String[] strings() default {};
	Class<?>[] classes() default {};

}

- 여러번의 파라미터를 던질 수 있도록 하는 어노테이션중 하나이다. 단 하나의 타입만 던질 수 있으며 사용법은 아래와같다.

- @ValueSource(ints = {10, 20, 30}) [O] , @ValueSource(ints = {10, 20, 30}, strings = {"문자1", "문자2", "문자3") [X]

- 단하나의 타입의 파라미터만 가능하므로 두번째 케이스는 에러가 날 것이다.

 

3. @CsvSource

@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(status = EXPERIMENTAL, since = "5.0")
@ArgumentsSource(CsvArgumentsProvider.class)
public @interface CsvSource {
	String[] value();

	char delimiter() default '\0';

	@API(status = EXPERIMENTAL, since = "5.6")
	String delimiterString() default "";

	@API(status = EXPERIMENTAL, since = "5.5")
	String emptyValue() default "";

	@API(status = EXPERIMENTAL, since = "5.6")
	String[] nullValues() default {};
}

- CsvSource의 value 속성으로 다음과 같이 파라미터를 던질 수 있다.

- @CsvSource({"10, '자바 스터디', '2020-04-02 14:20:21', true", "20, 스프링, '2020-04-02 14:20:21', false"})

- 이렇게 문자열로 구분자 콤마(,)를 기준으로 값을 짤라서 파라미터에 담아줄 것이다.

- 모든 문자는 String으로 주의하도록 한다.

 

단일 인자 @ValueSource 어노테이션 테스트 예제
@DisplayName("반복 테스트")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@ValueSource(ints = {10, 20, 30}) //단 1개의 타입만 넘겨야함.
void parameterizedTest_withConverter(int value) {
	assertTrue(value > 0, () -> "인수는 양수여야 합니다.");
	System.out.println("테스트 통과");
}

- 각각의 인자로 넘어올 파라미터는 10, 20, 30입니다. 모두 양의 정수가 파라미터로 넘어왔기 때문에 결과는 다음과 같습니다.

결과

 

그런데 위와 같은 상황은 사실 테스트할 필요가 거의 없는 케이스입니다. 왠만하면 눈에 보이는 결과인데 하나마나 무용지물입니다.

 

객체를 파라미터로 받아서 내부에 validation 어노테이션을 추가하여 알맞는 파라미터로 객체가 생성되는지 확인하는 테스트를 진행합니다.

 

@DisplayName("객체만들기 반복 테스트[SimpleArgumentConverter]")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@ValueSource(ints = {10, 20, 30}) //단 1개의 타입만 넘겨야함.
void parameterizedTest_withConverter(MyClass myClass) {
	assertNotNull(myClass, () -> "myClass 객체가 NULL 입니다.");
    System.out.println("테스트 통과");
}
결과

에러가 났습니다. 잘보면 Integer를 MyClass 타입으로 convert 할 수 없다고 나옵니다. 이를 위해 SimpleArgumentConverter 클래스를 상속받은 Convertor를 생성해주어야합니다. 

특정 테스트에서만 사용될 것으로 판단하여 내부 static 클래스로 생성하겠습니다

 

완성된 테스트

MyClass.java

import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;

public class MyClass {
    @NotNull(message = "value 값을 입력해주세요.")
    @Min(value = 1, message = "value 값은 0보다 큰 양수입니다.")
    private Integer value;

    public MyClass(@NotNull @Min(1) Integer value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return "MyClass{" +
                "value=" + value +
                '}';
    }
}

테스트 메소드

	@DisplayName("객체 만들기 반복 테스트[SimpleArgumentConverter]")
    @ParameterizedTest(name = "{index} {displayName} message={0}")
    @ValueSource(ints = {10, 20, 30}) //단 1개의 타입만 넘겨야함.
    void parameterizedTest_withConverter(@ConvertWith(MyClassConverter.class) MyClass myClass) {
        assertNotNull(myClass, () -> "myClass 객체가 NULL 입니다.");
        System.out.println("테스트 통과 : "+myClass.toString());
    }

    static class MyClassConverter extends SimpleArgumentConverter {

        @Override
        protected Object convert(Object source, Class<?> targetType) 
                                                    throws ArgumentConversionException {
            assertEquals(MyClass.class, targetType, 
            				() -> "MyClass 타입으로만 변환 가능합니다.");
            return new MyClass(Integer.parseInt(source.toString()));
        }
    }

* 참고로 알아보니 @NotNull 과 같은 어노테이션은 JUnit환경에서는 별도의 validator를 생성해야하는데 관련 주제가 너무 넓게 들어가다보니 생략하였습니다. MyClass의 어노테이션들은 현재는 동작하지 않고 Request에 의해서 파라미터가 셋팅될 때 동작하게 될것입니다.

- @ConvertWith 어노테이션으로 MyClassConverter 타입의 클래스타입을 인자로 넣어주고 파라미터 Converting을 해주면 MyClass 인자에 convert메소드에서 리턴된 MyClass 값이 들어가게 됩니다.

- 실질적으로 현업에서 테스트를 진행할 경우 여러가지 파라미터를 넘겨보며 많은 테스트 케이스들을 거쳐야하는데 다음과 같이 에러를 통과하는 테스트들과 아래와 같이 에러를 내는 테스트도 같이 내보면 좋을 것 같습니다.

 

에러용 테스트
@DisplayName("객체만들기 반복 테스트[SimpleArgumentConverter]")
    @ParameterizedTest(name = "{index} {displayName} message={0}")
    @ValueSource(ints = {-10, -20, -30})
    void parameterizedTest_withConverter_ERROR(@ConvertWith(MyClassConverter.class) MyClass myClass) {
        assertFalse(myClass.getValue() > 0, () -> "myClass의 value 값은 양수이어야 합니다.");
        System.out.println("테스트 통과 : "+myClass.toString());
    }

 

복합 인자 @CsvSource 어노테이션 테스트

새로 수정한 MyClass 입니다. 편의를 위해서 lombok 라이브러리 의존성을 추가하여 진행하였습니다.

@Getter
@ToString
@AllArgsConstructor
public class MyClass {
    @NotNull(message = "value 값을 입력해주세요.")
    @Min(value = 1, message = "value 값은 0보다 큰 양수입니다.")
    private Integer value;

    private String name;
    private LocalDateTime time;
    private boolean active;

    public MyClass(@NotNull @Min(1) Integer value) {
        this.value = value;
    }
}

여러가지 인자들을 테스트하기 위해 다양한 타입으로 변환될 수 있는 값들을 파라미터에 포함시켰습니다.

@DisplayName("객체만들기 반복 테스트[ArgumentsAggregator]")
    @ParameterizedTest(name = "{index} {displayName} message={0}")
    @CsvSource({"10, '자바 스터디', '2020-04-02 14:20:21', true",
                "20, 스프링, '2020-04-02 14:20:21', false"})
    void parameterizedTest_withAggregator(Integer limit,
                                          String name,
                                          String date,
                                          boolean active) {
        MyClass myClass = new MyClass(limit,
                name,
                LocalDateTime.parse(date, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
                active);
        assertNotNull(myClass, () -> "myClass가 NULL 입니다.!!!");
        System.out.println("테스트 통과!"+ myClass.toString());
    }

- 모든 argumentType은 String입니다. 하지만 Integer와 같이 동적으로 Casting 해주는 부분도 있으니 편리합니다.

- 각각의 CsvSource에 있는 구분자콤마로 구분된 파라미터들을 실제 메소드 파라미터에 매핑시켜주어 MyClass 인스턴스를 생성해줍니다.

 

결과

 

파라미터 MyClass로 받기

마찬가지로 저렇게 파라미터로 받고 MyClass 인스턴스를 생성하는 부분이 보일러 플레잇코드처럼 되어버립니다. 파라미터를 MyClass로 바로 받아보도록 하겠습니다.

 

마찬가지로 파라미터값을 MyClass로 변환시켜야하는데 이번에는 ArgumentsAggregator 인터페이스를 구현하여 처리해보겠습니다. 마찬가지로 해당 테스트에서만 사용될 클래스이기 때문에 내부 static 클래스로 생성하였습니다.

 

완성된 테스트
@DisplayName("객체만들기 반복 테스트[ArgumentsAggregator]")
    @ParameterizedTest(name = "{index} {displayName} message={0}")
    @CsvSource({"10, '자바 스터디', '2020-04-02 14:20:21', true",
            "20, 스프링, '2020-04-02 14:20:21', false"})
    void parameterizedTest_withAggregator(@AggregateWith(MyClassAggregator.class) MyClass myClass) {
        assertNotNull(myClass, () -> "myClass가 NULL 입니다.!!!");
        System.out.println("테스트 통과!"+ myClass.toString());
}

static class MyClassAggregator implements ArgumentsAggregator {

	@Override
    public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context) 
                                                 throws ArgumentsAggregationException {
        return new MyClass(accessor.getInteger(0),
                           accessor.getString(1),
                    LocalDateTime.parse(accessor.getString(2), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
                    accessor.getBoolean(3)
        );
    }
}

댓글

Designed by JB FACTORY