티스토리 뷰

반응형

이번 시간에는 JUnit5에서 제공하는 Parameterized 어노테이션에 대해 공부해보겠습니다.

 

테스트 코드 공부 시리즈

1. AssertJ를 이용한 테스트 코드 작성

2. JUnit의 Parameterized 어노테이션 사용하기

3. Spring AutoConfigure Annotation Test

4. Mockito 톺아보기 w.kotlin

5. kotlin에 특화된 mockito-kotlin 사용하기

6. postman을 이용한 시나리오 테스트 하기

 

Parameterized란?


매개 변수화 된 테스트를 통해 각각 다른 인수로 여러 번 테스트를 실행할 수 있게 도와주는 어노테이션입니다.

 

간단한 예시를 하나 먼저 보겠습니다.

@ParameterizedTest
@ValueSource(strings = ["racecar", "radar", "able was I ere I saw elba"])
fun `ValueSource 간단 예시`(candidate: String) {
    assertThat(candidate.length).isLessThan(10)
}

위 테스트 코드를 실행해보면 아래와 같은 결과가 나오는데요.

하나의 테스트코드를 실행을 했는데 3가지의 다른 경우에 대해 테스트가 진행된걸 확인할 수 있습니다.

이게 가능한 이유는 @ValueSource 어노테이션을 통해 지정한 배열을 파라미터 값으로 각각 넘겨주기 때문인데요.

@ValueSource 와 같은 어노테이션이 Parameterized 어노테이션입니다.

 

ParameterizedTest를 사용하면 각각의 파라미터를 별도의 함수처럼 호출되고 ConsoleLauncher가 위의 사진과 같이 보기좋게 출력해주게 됩니다.

 

그러면 다양한 Parameterized의 어노테이션을 살펴보겠습니다.

 

 

@ValueSource


@ValueSource

  • literal(리터럴)값의 단일 배열을 소스로 지정 할 수 있다.
  • 테스트 메서드 호출 마다 하나씩 전달되며, 하나의 인자만 받는 파라미터화 테스트에서만 사용이 가능하다.
@ParameterizedTest
@ValueSource(ints = [2, 3, 4])
fun `ValueSource 를 이용한 1보다 크고 5보다 작은 값 테스트`(num: Int) {
    assertThat(num).isGreaterThan(1).isLessThan(5)
}

ValueSource 어노테이션을 통해 리터럴 값으로 사용할 수 있는 타입 종류

  • short , byte, int, long, float, double, char, boolean, string, class

 

 

Null and Empty Source


@NullSource

  • 하나의 Null값을 인자로 제공한다.
  • 파라미터가 원시(primitive) 타입인 경우, 즉 null 값을 가질 수 없는 타입인 경우에는 사용할 수 없다.

@EmptySource

  • 하나의 Empty 값을 인자로 제공한다.
  • Empty 값이 들어갈 수 있는 타입에는 모두 사용가능하다.

 

@NullSource와 @EmptySource 어노테이션은 같이 사용이 가능합니다.

@ParameterizedTest
@NullSource
@EmptySource
fun `Null String & Empty Strings 테스트`(text: String?) {
    assertThat(text).isNullOrEmpty()
    assertThat(text).isBlank
}

 

두개의 어노테이션을 결합한 @NullAndEmptySource 어노테이션도 존재합니다.

@ParameterizedTest
@NullAndEmptySource  // @NullSource + @EmptySource
fun `NullAndEmpty Strings 테스트`(text: String?) {
    assertThat(text).isNullOrEmpty()
    assertThat(text).isBlank
}

 

여러 종류의 공백 문자열을 테스트 하고 싶다면 아래와 같이 테스트가 가능합니다.

@ParameterizedTest
@ValueSource(strings = [" ", "   ", "\t", "\n"])
fun `여러 종류의 공백 문자열 테스트`(text: String) {
    assertThat(text.trim()).isEmpty()
}

 

@EnumSource


@EnumSource

  • Enum(열거형)타입의 값의 배열을 테스트 메서드에 전달한다.
  • 테스트 메서드 호출 마다 하나씩 전달되며, 하나의 인자만 받는 파라미터화 테스트에서만 사용이 가능하다.
  • value 옵션을 통해 테스트에 사용할 enum 클래스를 지정할 수 있다.
    • 만약 value 옵션을 지정하지 않는다면, 첫번째 파라미터의 타입을 사용한다.
  • names 옵션으로 어떤 상수를 사용할지 지정할 수 있다.
    • names 를 지정하지 않을 경우 모든 상수가 사용된다.
    • 리터럴 문자열 외에도 정규 표현식(regular expression)을 names속성에 전달할 수 있다.
  • mode 속성으로 names로 제공된 값을 어떻게 처리할 것인지 선언할 수 있다.
    • ex) EXCLUDE, MATCH_ALL

 

Car Enum 클래스

enum class Car(name: String, price: Int) {
    BMW("bmw", 8000),
    TESLA("tesla", 7000),
    KIA("kia", 4000),
    AUDI("audi", 5000)
}

 

@EnumSource 기본 테스트

@ParameterizedTest
@EnumSource(Car::class)
fun `EnumSource 기본 사용법 테스트`(car: Car) {
    assertThat(car).isNotNull
}

 

만약 @EnumSource어노테이션의 value 옵션을 지정하지 않는다면 자동으로 메서드의 첫번재 파라미터 타입을 사용합니다.

@ParameterizedTest
@EnumSource
// 첫번째 파라미터인 Car 클래스 타입 사용
fun `EnumSource value 옵션 생략`(car: Car) {
    assertThat(car).isNotNull
}

 

Enum 타입의 값을 이용한 검증

@ParameterizedTest
@EnumSource(Car::class)
fun `car 이름 길이가 4보다 작은지 검증`(car: Car) {
    assertThat(car.name.length).isLessThan(4)
}

 

names 옵션을 이용해 특정 값만 검증

@ParameterizedTest
@EnumSource(value = Car::class, names = ["BMW", "KIA"])
fun `EnumSource name 옵션으로 길이가 4보다 작은 상수를 지정`(car: Car) {
    assertThat(car.name.length).isLessThan(4)
}

 

mode 옵션을 이용한 names로 제공된 값을 필터링

@ParameterizedTest
@EnumSource(
    value = Car::class,
    names = ["BMW", "KIA"],
    mode = EnumSource.Mode.EXCLUDE
)
fun `EnumSource mode Exclude 옵션 사용`(car: Car) {
    assertThat(car.name.length).isGreaterThanOrEqualTo(4)
}

 

MATCH_ALL속성으로 정규표현식 사용

@ParameterizedTest
// A로 끝나는 값만 사용
@EnumSource(mode = EnumSource.Mode.MATCH_ALL, names = ["^.*A$"])
fun `EnumSource mode MATCH_ALL 정규표현식 사용`(car: Car) {
    assertThat(car.name.length).isLessThan(4)
}

mode 속성으론 INCLUDE , EXCLUDE , MATCH_ALL , MATCH_ANY , MATCH_NONE 이 사용가능합니다.

기본값은 INCLUDE로 설정되어 있습니다.

 

 

 

@MethodSource


@MethodSource

  • 테스트 클래스 또는 외부 클래스의 하나 이상의 팩토리 메서드를 참조할 수 있다.
    • 명시적으로 팩토리 메서드를 지정하지 않은 경우 관례에 따라 현재 @ParameterziedMethod와 동일한 이름의 팩토리 메서드를 찾게 된다.
  • 팩토리 메서드는 반드시 static 이어야 한다.
    • 예외적으로 @TestInstance(Lifecycle.PER_CLASS) 를 사용할 경우 테스트 클래스에 위치한 팩토리 메서드는 static이 아닐 수 있다.
  • 팩토리 메서드는 반드시 인자를 받지 않아야 한다.

 

MethodSource, Stream 단일 매개변수 사용

@ParameterizedTest
@MethodSource("stringProvider")
fun `MethodSource 팩토리 메서드 지정 Test`(argument: String) {
    assertThat(argument).isNotNull
}

companion object {
    // 팩토리 메서드는 반드시 static 이어야 함
    @JvmStatic
    fun stringProvider(): Stream<String> {
        return Stream.of("TESLA", "KIA")
    }
}

 

MethodSource, 팩토리 메서드 이름을 명시X

@ParameterizedTest
@MethodSource
fun `MethodSource Default값 Test`(argument: String) {
    assertThat(argument).isNotNull
}

companion object {
    @JvmStatic
    // 팩토리 메서드 이름을 명시하지 않으면
    // 관례적으로 현재 메서드와 동일한 이름을 가진 팩토리 메서드를 검색
    fun `MethodSource Default값 Test`(): Stream<String> {
        return Stream.of("TESLA", "KIA")
    }
}

만약 테스트 메서드 이름을 한글로 작성하셨다면 팩토리 메서드도 동일하게 한글로 작성하여도 무방합니다.

 

 

MethodSource, Stream의 다양한 유형 지원

@ParameterizedTest
@MethodSource("range")
fun `MethodSource 숫자 짝수 검증 Test`(argument: Int) {
    val isEven = checkEvenOdd(argument)
    assertThat(isEven).isTrue
}

private fun checkEvenOdd(argument: Int): Boolean {
    val remainder = argument % 2
    return remainder == 0
}

companion object {
    @JvmStatic
    // IntStream 을 통해 int형 값을 사용
    fun range(): IntStream {
        return IntStream.range(0, 7)
    }
}
  • 각 팩토리 메서드는 반드시 Stream타입을 생성해야 합니다.
  • Streams는 여러가지 primitive 타입을 지원합니다. IntStream , DoubleStream , LongStream
  • primitive 타입 외에도 Stream, Collection , Iterator , Iterable , 객체 배열, 원시 값 배열등의 타입도 지원하고 있습니다.

 

MethodSource 파라미터 여러개 전달

@ParameterizedTest
@MethodSource("stringIntAndListProvider")
fun `MethodSource 파라미터 여러개 전달 Test`(str: String, num: Int, list: List<String>) {
    assertThat(str.length).isEqualTo(5)
    assertThat(num).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(2)
    assertThat(list).size().isEqualTo(2)
}

companion object {
    @JvmStatic
    fun stringIntAndListProvider(): Stream<Arguments> {
        return Stream.of(
            Arguments.arguments("apple", 1, listOf("a", "b", "c")),
            Arguments.arguments("lemon", 2, listOf("x", "y"))
        )
    }
}

테스트 메서드에 여러 파라미터를 전달할려면 Arguments 인스턴스를 사용하면 됩니다.

 

 

@CsvSource


@CsvSource

  • 인자의 리스트를 CSV방식(, 콤마로 값을 구분)으로 표현 할 수 있다.
  • useHeaderInDisplayName 옵션을 통해 CSV 헤더를 사용 할 수 있다.
  • delimiter 기본 값은 콤마(,)다.
  • 빈 인용구(’’)는 빈 문자열로, 완전히 빈 값은 null로 해석된다.
  Example Input Resulting Argument List
기본 형태 @CsvSource({ “apple, banana” }) “apple” , “banana”
인용구를 이용 @CsvSource({ “apple, ‘lemon, limeb’” }) “apple”, “lemon, lime”
빈 문자열 @CsvSource({ “apple, ‘’” }) “apple”, “”
null 값 @CsvSource({ “apple, ” }) “apple”, null

 

CsvSource 기본 테스트

@ParameterizedTest
@CsvSource(
    "apple, 1",
    "banana, 2",
    "'lemon, lime', 0xF1",
    "strawberry, 700_000"
)
fun `CsvSource 기본 테스트`(fruit: String, rank: Int) {
    assertThat(fruit).isNotNull
    assertThat(rank).isNotEqualTo(0)
}

 

CsvSource useHeadersInDisplayName 옵션 테스트

@ParameterizedTest(name = "[{index}] {arguments}")
@CsvSource(useHeadersInDisplayName = true, textBlock = """
    FRUIT, RANK
    apple, 1
    banana, 2
    'lemon, lime', 0xF1
    strawberry, 700_000 """)
fun `CsvSource useHeadersInDisplayName 옵션 테스트`(fruit: String, rank: Int) {
    assertThat(fruit).isNotNull
    assertThat(rank).isNotEqualTo(0)
}

  • useHeadersInDisplayName 옵션을 통해 FRUIT, RANK 값을 헤더로 설정해줄 수 있습니다.
  • textBlock 옵션은 사용중인 프로그래밍 언어가 텍스트 블록을 지원하는 경우 사용가능합니다. (코틀린은 텍스트 블록을 지원하고 있습니다.)

 

CsvSource 여러 옵션 변경

    @ParameterizedTest
    @CsvSource(delimiter = '|', quoteCharacter = '"', textBlock = """
#-----------------------------
#    FRUIT     |     RANK
#-----------------------------
     apple     |      1
#-----------------------------
     banana    |      2
#-----------------------------
  "lemon lime" |     0xF1
#-----------------------------
   strawberry  |    700_000
#-----------------------------
""")
    fun `CsvSource 구분자, 인용기호 옵션 테스트`(fruit: String, rank: Int) {
        assertThat(fruit).isNotNull
        assertThat(rank).isNotEqualTo(0)
    }

delimiter(구분자), quoteCharacter(인용자) 옵션을 통해서 값 변경이 가능합니다.

 

여기서 위의 코드의 indent가 이상하다고 느끼셨을텐데요.

JUnit 5 공식문서에 보면 아래와 같은 글을 볼 수 있습니다.

글의 내용을 보면 코틀린 언어의 경우 textBlock안에 comment(주석) or 인용문구안에 줄바꿈이 포함되어 있다면 자동으로 빈공간을 지워주지 못한다고 합니다.

따라서 의도적으로 빈공간을 만들지 않기 위해 주석부분을 모두 제일 왼쪽으로 보내준것입니다.

 

 

    @ParameterizedTest
    @CsvSource(delimiter = '|', quoteCharacter = '"', textBlock = """
    #-----------------------------
    #    FRUIT     |     RANK
    #-----------------------------
         apple     |      1
         banana    |      2
      "lemon lime" |     0xF1
       strawberry  |    700_000
    """)
    fun `CsvSource 구분자, 인용기호 옵션 테스트`(fruit: String, rank: Int) {
        assertThat(fruit).isNotNull
        assertThat(rank).isNotEqualTo(0)
    }

만약 의도적으로 주석의 왼쪽부분에 빈공간을 지우지 않고 실행하면 주석이 제대로 인식되지 않는것을 볼 수 있습니다.

또한 마지막 8번째를 보시면 null 값이 들어가 있는걸 볼 수 있는데요.

해당부분은 """ 마지막 부분도 왼쪽에 공간이 존재해 null 값이 인식되어 들어가는걸 볼 수 있습니다.

""" 부분은 위의 useHeadersInDisplayName에서 사용했던 방식처럼 마지막 값을 넣어주는 부분 오른쪽에 위치시키는것도 한 방법입니다. (strawberry, 700_000 """)

 

 

@ParameterizedTest
@CsvSource(delimiter = '|', quoteCharacter = '"', textBlock = """
     apple     |      1
     banana    |      2
  "lemon 
  lime" |     0xF1
   strawberry  |    700_000 """)
fun `CsvSource 구분자, 인용기호 옵션 테스트`(fruit: String, rank: Int) {
    assertThat(fruit).isNotNull
    assertThat(rank).isNotEqualTo(0)
}

다만 공식문서에 있는 내용과 다르게 lemon lime 처럼 인용문구 안에 줄바꿈을 한 다음 테스트를 실행해 보았는데요. 해당 경우에는 테스트가 정상적으로 실행되었습니다.

 

따라서 Kotlin언어로 CsvSource의 textBlock 옵션을 사용하실때는 주석부분을 주의해서 사용하시면 될것 같습니다!

 

 

@ArgumentsSource


@ArgumentsSource

  • 재사용 가능한 사용자 지정 ArgumentsProvider를 지정하는데 사용할 수 있다.
  • ArgumentsProvider의 구현은 최상위 클래스 or 정적 중첩 클래스로 선언되어야 합니다.
class ArgumentsSourceTest {

    @ParameterizedTest
    @ArgumentsSource(MyArgumentsProvider::class)
    fun testWithArgumentsSource(argument: String) {
        Assertions.assertThat(argument).isNotNull
    }
}

class MyArgumentsProvider : ArgumentsProvider {
    override fun provideArguments(context: ExtensionContext?): Stream<out Arguments> {
        return Stream.of("apple", "banana").map(Arguments::of)
    }
}

 

Argument Aggregation


MethodSource의 Argument 인스턴스를 사용해 여러 인자를 넘기를 경우에는 보통 전달할려는 인자에 대응되는 파라미터가 존재해야 합니다.

이에 따라 많은 수의 인자를 제공하는 경우 메서드 파라미터가 비대해지게 되는데요.

이러한 경우 ArgumentsAccessor를 통해 여러 파라미터를 간단하게 넘길 수 있습니다.

 

ArgumentsAccessor 사용 장점

  • ArgumentsAccessor 를 사용하면 여러 파라미터를 간단하게 넘길 수 있습니다.
  • MethodSource의 Argument 인스턴스를 사용해 전달할려는 인자에 대응되는 파라미터를 매번 만들 필요 없이, ArgumentsAccessor를 통해 간단하게 값을 가져올 수 있습니다.
  • ArgumentsAccessor 를 사용하면 코드를 더 간결하게 유지할 수 있습니다.

 

Argument Aggregation 테스트

data class Person(
    val firstName: String,
    val lastName: String,
    val gender: Gender,
    val dateOfBirth: LocalDate
)

enum class Gender {
    F,
    M
}

@ParameterizedTest
@CsvSource(
    "Jane, Doe, F, 1990-05-20",
    "John, Doe, M, 1990-10-22"
)
fun `Arguments Accessor 사용`(arguments: ArgumentsAccessor) {
    val person = Person(
        arguments.getString(0),
        arguments.getString(1),
        arguments.get(2, Gender::class.java),
        arguments.get(3, LocalDate::class.java)
    )

    if (person.firstName == "Jane") {
        assertThat(Gender.F).isEqualTo(person.gender)
    } else {
        assertThat(Gender.M).isEqualTo(person.gender)
    }

    assertThat(person.lastName).isEqualTo("Doe")
    assertThat(1990).isEqualTo(person.dateOfBirth.year)
}

CsvSource로 전달하는 인자가 4개이므로 원래라면 총 4개의 파라미터가 필요하지만 ArgumentsAccessor를 사용하면 제공된 인자들을 하나의 인자(arguments)로 접근이 가능합니다.

ArgumentsAccessor는 타입 변환도 제공하고 있어 arguments.get(2, Gender::class.java) 와 같이 타입 변환이 가능합니다.

 

 

Custom Display Name


기본적으로는 매개 변수화 된 테스트의 경우 디스플레이에 해당 호출에 대한 모든 인수의 문자열을 포함합니다.

@ParameteredTest 어노테이션의 이름 속성을 사용하면 호출 표시 이름을 커스텀하게 변경이 가능하여 가독성을 높일 수 있습니다.

 

지원하는 이름 속성

PlaceHolder Description
{displayName} 메서드의 표시 이름
{index} 현재 호출 인덱스 (1부터 시작)
{arguments} 콤마로 구분된 완전한 인자의 목록
{argumentsWithNames} 콤마로 구분된 완전한 인자의 목록 (파라미터 이름 포함)
{0}, {1}, … 각 개별 인자

 

다양한 이름 속성 사용

@DisplayName("Display name of container")
@ParameterizedTest(name = "{index} ==> the rank of ''{0}'' is {1} | {arguments} | {argumentsWithNames}")
@CsvSource("apple, 1", "banana, 2", "'lemon, lime', 3")
fun `Custom Display Name 사용`(fruit: String, rank: Int){
}

 

속성에 따른 출력 형태

  • the rank of ‘’{0}’’ is {1} → the rank of ‘apple’ is 1
  • {arguments} → apple, 1
  • {argumentsWithNames} → fruit=apple, rank=1

 

 

마무리

다른 사람이 짠 테스트코드를 보다보면 심심치 않게 Parameterized 어노테이션을 볼 수 있었는데요.

왜 Parameterized 어노테이션을 사용해서 테스트 코드를 작성하는지 이해할 수 있었고

잘 활용해서 사용한다면 테스트 코드의 중복을 줄이고 깔끔하게 작성할 수 있겠다는 생각이 들었습니다.

다음 글에서는 Spring이 지원하는 AutoConfiguration Annotation에 대해 공부해보겠습니다!!

 

 

참고자료

https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests

 

JUnit 5 User Guide

Although the JUnit Jupiter programming model and extension model do not support JUnit 4 features such as Rules and Runners natively, it is not expected that source code maintainers will need to update all of their existing tests, test extensions, and custo

junit.org

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
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 29 30 31
글 보관함