9장 테스트

코틀린 쿡북 9장을 요약한 내용 입니다.

테스트 클래스 생명주기 설정하기

Junit 5의 테스트 수명주기를 기본값인 테스트 함수당 한 번 대신 클래스 인스턴스당 한 번씩 인스턴스화하고 싶다면 @TestInstance 애노테이션을 하용하거나 junit-platform.properties 파일의 lifecyle.default 속성을 설정하라

기본적으로 JUnit 4는 각 테스트 메소드마다 테스트 클래스의 새 인스턴스를 생성한다. 이러한 방식은 테스트 클래스 속성이 테스트마다 매번 다시 초기화되므로 테스트 자체로 독립적이지만 초기화 코드가 각 테스트마다 반복해서 실행된다는 단점이 있다.

자바에서는 이 문제를 해결하기 위해 클래스의 모든 속성을 static으로 표시해 모든 초기화 코드를 딱 한번만 실행되는 @BeforeClass 애노테이션이 달린 static 메소드 안에 배치할 수 있다.

public class JUnit4ListTests {
	private static List<String> strings = 
		Arrays.asList("this", "is", "a", "list", "of", "strings");

	private List<Integer> modifiable = new ArrayList<>();

	@BeforeClass
	public static void runBefore() {
		System.out.println("BeforeClass: " + strings);
	}

	@Before
  public void intialize() {
      System.out.println("Before: " + modifiable)
      modifiable.add(3)
      modifiable.add(1)
      modifiable.add(4)
      modifiable.add(1)
      modifiable.add(5)
  }

	...
}

modifiable 리스트는 선언 시에 빈 리스트로 초기화된 다음 @Before initialize 메소드 안에서 항목을 추가한다. 테스트에 진입할 때마다 빈 modifiable 리스트를 출력한 다음 modifiable 리스트에 원소를 추가한다.

코틀린에서 자바와 동일한 동작을 구현하려고 할 때 코틀린에는 static 키워드가 없다는 문제를 바로 직면하게 된다.

strings 리스트를 initialize 메소드 안에서 인스턴스화하려면 strings 속성을 val 타입이 아닌 lateinit과 var로 선언해야 한다.

class JUnit4ListTests {
    companion object {
        @JvmStatic
        private val strings = listOf("this", "is", "a", "list", "of", "strings")

        @BeforeClass @JvmStatic
        fun runBefore() {
            println("BeforeClass: $strings")
        }

        @AfterClass @JvmStatic
        fun runAfter() {
            println("AfterClass: $strings")
        }
    }

    private val modifiable = ArrayList<Int>()

    @Before
    fun setUp() {
        println("Before: $modifiable")
        modifiable.add(3)
        modifiable.add(1)
        modifiable.add(4)
        modifiable.add(1)
        modifiable.add(5)
    }

    @After
    fun finish() {
        println("After: $modifiable")
    }

    @Test
    fun addElementsToList() {
        modifiable.add(9)
        modifiable.add(2)
        modifiable.add(6)
        modifiable.add(5)
        assertEquals(9, modifiable.size)
    }

    @Test
    fun size() {
        println("Testing size")
        assertEquals(6, strings.size)
        assertEquals(5, modifiable.size)
    }

    @Test(expected = ArrayIndexOutOfBoundsException::class)
    fun accessBeyondEndThrowsException() {
        println("Testing out of bounds exception")
        strings[99]
        assertEquals(6, strings.size)
    }
}

Junit 5는 더 간단한 방법을 제공한다. Junit 5는 테스트 클래스에서 @TestInstance 애노테이션을 사용해 수명주기를 명시할 수 있다.

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class JUnit5ListTests {
    private val strings = listOf("aa", "bb", "cc")
    private lateinit var modifiable : MutableList<Int>
    
    @BeforeEach
    fun setUp() {
        modifiable = mutableListOf(3,1,4,1,5) // 테스트할 때마다 실행 전 다시 초기화
        println("Before: $modifiable")
    }
    
    @AfterEach
    fun finish () {
        println("After: $modifiable")
    }
    
    @Test
    fun test1 () {
        //
    }
    
    @Test
    fun test2 () {
        //
    }
}

테스트 인스턴스의 수명주기를 PER_CLASS로 설정하면 테스트 함수의 양과 상관 없이 테스트 인스턴스가 딱 하나만 생성된다. JUnit 5에서는 @TestInstance 애노테이션을 각 테스트 클래스에 반복하는 대신 모든 테스트의 수명주기를 properties 파일에 설정할 수 있다.

junit.jupiter.testinstance.lifecycle.default = per_class

테스트에 데이터 클래스 사용하기

코틀린의 데이터 클래스는 equals, toString, hashCode, copy와 구조 분해를 위한 component 메소드가 자동으로 포함된다. 그러므로 테스터 클래스를 데이터 클래스를 사용하면 코드를 부풀리지 않고 객체의 여러 속성을 체크할 수 있다.

data class Book (
    val isbn: String,
    val title: String,
    val author: String,
    val pulished: LodalDate
)

Book 데이터 클래스의 모든 속성을 수동으로 테스트할 수 있다.

@Test
internal fun `test book the hard way` () {
    val book = service.findBookById("12345")
    assertThat(book.isbn, `is`("12345"))
    assertThat(book.title, `is`("kotlin cookbook")) // 만약 여기서 실패하면 테스트 끝.
    assertThat(book.author, `is`("author of book"))
    assertThat(book.published, `is`(LocalDate.of(2020, Month.AUGUST, 31)))
}

테스트는 잘 동작하겠지만 모든 속성의 assertion을 직접 작성해야 한다. 또한 첫 번째 assertion이 실패하면 모든 테스트가 실패하는데 이는 Junit 5 에 Executable 인스턴스의 assertAll 메소드를 사용하면 된다.

@Test
fun `use JUnit 5 assertAll` () {
    val book = service.findBookById("1")
    assertAll("check all properties of a book",
        {assertThat(book.isbn, `is`("12345"))},
        {assertThat(book.title, `is`("kotlin cookbook"))},
        {assertThat(book.author, `is`("author of book"))},
        {assertThat(book.published, `is`(LocalDate.of(2020, Month.AUGUST, 31)))}
    )
}

코틀린 data 클래스에는 이미 equals 메소드가 올바르게 구현돼 있으므로 테스트 프로세스를 간소화할 수 있다.

@Test
internal fun `use data class` () {
    val book = service.findBookById("12345")
    val expected = Book(
			isbn = "12345",
	    title = "kotlin cookbook",
	    author = "author of book",
	    published = LocalDate.of(2020, Month.AUGUST, 31))
    
    // 하나의 단언이 모든 속성을 테스트한다.
    assertThat(book, `is`(expected));
}

컬렉션 인스턴스는 다음처럼 햄크레스트 논리 판정 함수가 제공하는 메소드를 이용해 컬렉션의 모든 원소를 확인할 수 있다. 해당 예제에서는 배열을 개별 항목으로 확장하기 위해 expected에 배열 펼침 연산자(*)를 사용했다.

@Test
internal fun `check all elements in list` () {
    val found = service.findAllBooksById("12345","67890")
    val expected = arrayOf(
        Book("12345","kotlin cookbook","author of book",LocalDate.parse("2020-08-31")),
        Book("67890","java cookbook","author2 of book",LocalDate.parse("2019-08-31"))
    )
        
    assertThat(found, arrayContainingInAnyOrder(*expected));
}

기본 인자와 함께 도움 함수 사용하기

테스트 객체를 빠르게 생성하고 싶을 경우 기본 인자를 가진 도움(helper) 함수를 사용하라

우선 기본값을 생성하는 팩토리 함수를 추가하자

fun createBook (
    isbn: String = "12345",
    title: String = "Modern Java",
    author: String = "Java Author",
    published: LocalDate = LocalDate.parse("2019-08-31")
) = Book(isbn, title, author, published)

기본 인자는 오직 테스트 데이터를 생성하기 위해 사용됐기 때문에 도메인 클래스 자체에 기본인자를 추가할 필요가 없다. 이론상 똑같은 일을 하기 위해 data 클래스에서 제공되는 copy 클래스에서 제공되는 copy 함수를 사용할 수 있지만 copy 함수의 광범위하게 사용하면 특히 중첩 구조에서 가독성이 떨어진다.

val modernJava = createBook() // 팩토리 함수의 모든 기본값 사용 
val makingJava = createBook( // 저자(author)가 같음. 
    isbn = "54321",
    title = "Making Java",
    published = LocalDate.parse("2020-08-31")
)

여러 데이터에 JUnit 5 테스트 반복하기

데이터 값 세트를 제공해서 JUnit 5 테스트를 실행하고 싶을 경우에 파라미터화된 테스트와 동적 테스트를 사용하라

피보나티 수열을 테스트하는 로직을 작성해보자

@Test
fun `Fibonacci numbers (hard way)`() {
    assertAll(
        {assertThat(fibonacci(3), `is`(2))},
        {assertThat(fibonacci(4), `is`(5))},  
        {assertThat(fibonacci(9), `is`(34))}
    )
}

이를 CSV 소스를 사용하는 파라미터화된 테스트로 재구성할 수 있다.

// 하나의 메소드로 여러 개의 파라미터에 대해 테스트 실행할 수 있도록 한다.
@ParameterizedTest
// 쉼표로 구분된 문자열 리스트를 인자로 받는다. 각 데이터는 "n,fib" 형식.
@CsvSource("1,1","2,1","3,2","4,3","5,5","6,8","7,13","8,21","9,34","10,55")
fun `first 10 Fibonacci numbers (csv)`(n: Int, fib: Int) = assertThat(fibonacci(n), `is`(fib))

또는 수명주기가 Lifecycle.PER_CLASS를 사용했다면 다음과 같이 @MethodSource로 해당 함수를 간단하게 참조할 수 있다.

private fun fibnumbers() = listOf(
    Arguments.of(1,1),Arguments.of(2,1),
    Arguments.of(3,2),Arguments.of(4,3),
    Arguments.of(5,5),Arguments.of(6,8),
    Arguments.of(7,13),Arguments.of(8,21),
    Arguments.of(9,34),Arguments.of(10,55)
)

@ParameterizedTest
@MethodSource("fibnumbers")
fun `first 10 Fibonacci numbers (method)`(n: Int, fib: Int) = assertThat(fibonacci(n), `is`(fib))

테스트 수명주기가 기본 옵션인 Lifecycle.PER_METHOD라면 다음과 같이 테스트 데이터 소스 함수를 동반 객체 안에 위키시켜야 한다.

Junit은 두 입력 인자를 결합시킬 수 있는 of라는 팩토리 메소드를 가진 Arguments 클래스를 제공한다. of의 리턴 타입은 테스트 메소드에 사용할 수 있는 각각의 원소가 두 개의 입력 인자를 가지고 있는 List<Arguments>이다.

companion object {
    @JvmStatic
    fun fibnumbers() = listOf(
        Arguments.of(1,1),Arguments.of(2,1),
        Arguments.of(3,2),Arguments.of(4,3),
        Arguments.of(5,5),Arguments.of(6,8),
        Arguments.of(7,13),Arguments.of(8,21),
        Arguments.of(9,34),Arguments.of(10,55)
    )
}

@ParameterizedTest 
@MethodSource("fibnumbers")
fun `first 10 Fibonacci numbers (companion method)`(n: Int, fib: Int) = assertThat(fibonacci(n), `is`(fib))

파라미터화된 테스트에 data 클래스 사용하기

파라미터화된 테스트를 좀 더 쉽게 읽는 테스트 결과를 생성하고 싶을 경우엔 입력 값과 예상 값을 감싸는 data 클래스를 만들고, 만든 data 클래스 기반의 테스트 데이터를 생성하는 함수를 메소드 소스로 사용하라

코틀린 데이터 클래스는 이미 toString이 재정의 돼 있기 때문에 입력과 출력 쌍을 나타내는 data 클래스를 인스턴스화하는 파라미터화 된 테스트를 사용하는 테스트 메소드를 작성할 수 있다.

data class FibonacciTestData(val number: Int, val expected: Int)

@ParameterizedTest
@MethodSource("fibnumbers")
fun `Fibonacci numbers (data class)`(data: FibonacciTestData) {
 assertThat(fibonacci(data.number), `is`(data.expected))
}

private fun fibnumbers() = Stream.of(
 FibonacciTestData(number = 1, expected = 1),
 FibonacciTestData(number = 2, expected = 1),
 FibonacciTestData(number = 3, expected = 2),
 FibonacciTestData(number = 4, expected = 3),
 FibonacciTestData(number = 5, expected = 5),
 FibonacciTestData(number = 6, expected = 8),
 FibonacciTestData(number = 7, expected = 13)
)

Last updated