코틀린 테스팅 프레임워크 - Kotest

Kotlin의 테스팅 프레임워크에 대해 소개한다.

Kotest란?

Java에서는 예전부터 JUnit을 테스트에 많이 사용해 왔다. Kotlin에서도 JUnit으로 테스트를 사용하는 것이 가능하고 많이 사용되기도 한다. JUnit은 Java로 구현 테스팅 프레임워크이지만, Kotest는 순정 Kotlin으로 구현된 테스팅 프레임워크이다. Kotest라는 테스트 프레임워크를 사용하면 JUnit에 비해 Kotlin의 Syntax를 사용할 수 있으므로 코드 양을 줄일 수 있다. 그리고 테스트 케이스를 중첩하여 쓸 수 있는 이점이 있다.

자세한 내용은 공식 사이트에서 정리되어 있는데, 여기서 간단히 소개를 하자면, 큰 특징으로서는 10가지 종류의 Spec이라고 하는 클래스가 준비되어 있고, 원하는 Spec를 선택해 테스트를 쓰는 것이 수 있다. Spec은 각각 다양한 언어, 테스팅 프레임워크의 영향을 받아 만들어지고 있으며 다른 언어에서 Kotlin을 시작한 사람은 자신의 모국어 테스트 Spec을 선택할 수 있다.

Kotest 공식 사이트

그 밖에도 실험적인 기능을 포함한 많은 기능과 Assert, Extension이 준비되어 있다.

여기에서는 Kotest의 기본 구문에 대해 정리하고, Kotest를 지원하는 라이브러리에 대해서도 설명한다.

원래 Kotest란?

Kotlin에서 사용할 수 있는 테스팅 프레임워크이다. 이전에는 KotlinTest라는 이름이었지만, 릴리스 4.0부터 Jetbrains 제공 패키지와의 혼동을 피하기 위해 Kotest로 이름이 변경되었다.

먼저 샘플 코드를 살펴보겠다. 아래 코는 공식 샘플 테스트 코드이다.

class MyTests : StringSpec({
    "length should return size of string" {
        "hello".length shouldBe 5
    }
    "startsWith should test for a prefix" {
        "world" should startWith("wor")
    }
})

의존성 추가

먼저, kotest를 사용하기 위해서 dependency를 추가해 줘야 한다.

dependencies {
    testImplementation("io.kotest:kotest-runner-junit5-jvm:5.6.2")
    testImplementation("io.kotest:kotest-framework-datatest:5.6.2")
}

Kotest를 사용하는 것만으로는 위의 종속성만으로도 되지만, 데이터 구동 테스트를 작성하는 경우는 별도의 모듈로 준비되어 있으므로 위의 두 가지를 추가한다.

IntelliJ IDEA 에서 Kotest 환경 만들기

IntelliJ IDEA에서 Kotest 구동하려면, Kotest 플러그인을 설치해야 한다.

[Setting]-[Plugins]에서 가서 kotest를 검색해서 설치한다.

Kotest Plugin

기본적으로 Kotest 사용법

일단 간단히 동작을 확인해 보도록 하겠다.

테스트 클래스 - StringSpec을 사용한 테스트

여기서는 테스트 코드를 StringSpec 사용하는 스타일을 설명하도록 하겠다.

Kotest에는 테스트 클래스가 어느 Spec 상속하는지에 따라, 테스트의 작성하는 방법도 다르다. 그 밖에도 WordSpec,FunSpec, ShouldSpec 등이 있다. 각 Spec의 차이는 공식 페이지가 참고가 되므로, 설명은 생략한다.

앞서 언급했듯이 Spec에 따라 쓰는 방법이 다르지만, StringSpec에는 다음과 같이 두가지 방법이 있다.

StringSpec의 생성자에 람다로 전달하는 방법

class CalcTest : StringSpec({
    // 여기에 테스트를 작성
})

아래 코드는 매우 간단하게 쓸 수 있는 StringSpec에 의한 테스트의 작성 방법이다.

package com.devkuma.kotest

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

class CalcTest : StringSpec({
    "test" {
        1 + 1 shouldBe 2
    }
})

테스트 클래스에 원하는 Spec 클래스를 상속받아 생성자 인수에 처리 블록을 건네주는 것으로 테스트를 작성해 간다.

init 블록을 사용한 작성하는 방법

class CalcTest : StringSpec(){
    init {
      // 여기에 테스트를 작성
    }
}

위 테스트 코드를 아래와 같이 init 블록으로 감싸서 작성 수도 있다.

class CalcTest : StringSpec() {
    init {
        "test" {
            1 + 1 shouldBe 2
        }
    }
}

어느 밥법이도 좋다고 생각되지만, 코드 중첩(nested)이 깊어지면 코드가 가독성이 떨어질 수 있기에 전자를 추천한다.

여기에서는 생성자에 람다를 건네주는 쓰는 방법으로 사용하도록 하겠다.

Assert에는 Kotest에서 제공되어 있는 shouldBe을 사용하고 있지만, JUnit으로 말하면 assertThat와 거의 같다고 생각해도 된다. 대체로 이걸 많이 사용한다.

매치 함수

kotest로 매치 함수를 테스트 하기 위한 테스트 대상 클래스는 다음과 같이 작성한다.

class Calc {
    fun plus(a: Int, b: Int): Int {
        return a + b
    }

    fun devide(a: Int, b: Int): Int {
        return a / b
    }
}

그러면, 실제 테스트를 작성해 보도록 하겠다.

기본 단언문 - shouldBe

아래 테스트와 같이 shouldBe 사용하여 값의 정당성을 확인할 수 있다.

StringSpec를 상속하게 되면, 캐릭터 라인으로 테스트 내용을 표현하고, {}으로 둘러싼 부분에 실제의 테스트를 작성하게 방식이다.

class CalcTest : StringSpec({
    "1 + 1는 2이다" {
        val calc = Calc()
        calc.plus(1, 1) shouldBe 2
    }

    "10 + 3는 13이다" {
        val calc = Calc()
        calc.plus(10, 3) shouldBe 13
    }
})

그리고 shouldBe와 같이 “값이 그렇게 되어 있어야 한다"의 부정을 나타내는 shouldNotBe도 있다.

    "1 + 4는 4가 아니다." {
        val calc = Calc()
        calc.plus(1,4) shouldNotBe 4
    }

여기는 함수명 대로 인수에 전달된 값이 되어 있지 않았기에 테스트는 성공하게 된다.

예외 테스트 - shouldThrow

예외를 테스트하려면 shouldThrow을 사용한다.

    "0으로 나눈 경우 Exception이 발생한다." {
        val calc = Calc()
        shouldThrow<ArithmeticException> {
            calc.devide(10, 0)
        }
    }

그리고 shouldThrow는 발생한 예외 객체를 반환값으로 하기 위해, 아래와 같이 예외 객체에 대한 테스트를 쓸 수도 있다.

        val exception = shouldThrow<ArithmeticException> {
            calc.devide(10, 0)
        }
        exception.message shouldBe "/ by zero"

다만, shouldThrow는 지정한 예외 혹은 그 하위 예외가 발생한 경우에 단언문가 성공이 되는다는 점에 주의해야 한다. 특정 예외가 정확하게 발생했는지 확인하려면 shouldThrowExactly을 사용한다.

데이터 구동 테스트(Data Driven Testing)

데이터 구동 테스트는 입력 값과 출력 결과 값의 패턴을 작성하여, 테스트를 수행하는 방식이다. 데이터 구동이므로 이 데이터 패턴을 우선 작성하는 것이 주된 일로, 단언문 부분은 함수 호출하기만 하면 된다.

데이터 구동 테스트 동작 확인

예제를 보는 것이 이해하기 쉽기 때문에, 간단한 데이터 구동 테스트를 작성하면 다음과 같다.

package com.devkuma.kotest

import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe

enum class Operator {
    ADD, SUBTRACTION, MULTIPLICATION, DIVIDE
}

fun calculate(num1: Int, num2: Int, operator: Operator): Int {
    return when (operator) {
        Operator.ADD -> num1 + num2
        Operator.SUBTRACTION -> num1 - num2
        Operator.MULTIPLICATION -> num1 * num2
        else -> num1 / num2
    }
}

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Int)
        withData(
            TestPattern(1, 1, Operator.ADD, 2),
            TestPattern(3, 1, Operator.SUBTRACTION, 2),
            TestPattern(2, 3, Operator.MULTIPLICATION, 6),
            TestPattern(10, 5, Operator.DIVIDE, 2),
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

인수로 지정된 수치와 연산자로 계산한 결과를 반환하는 함수를 테스트하고 있다. 먼저 데이터 구동 테스트를 Kotest로 작성하는 경우 중첩할 수 있는 Spec이어야 하므로 StringSpec 이외의 Spec을 선택해야 한다. 뭐든 상관 없지만 여기서는 FunSpec을 선택하였다.

FunSpec의 경우는 context 블록안에 입력과 기대하는 출력을 포함하는 데이터 테이블을 데이터 클래스로 표현한다. 여기서는 calculate 메서드의 세 개의 인수와 예상되는 반환 값을 저장하도록 설정한다.

withData 함수의 인수에 정의한 데이터 클래스를 사용하여 데이터 패턴을 제공한다. 테스트하고 싶은 패턴이 모두 쓸 수 있으면 마지막으로 데이터 클래스의 모든 인수를 인수로 한 람다 중에서 처리를 실행한다.

이렇게 하면 다음과 같은 결과를 얻을 수 있다.

Test Results
  com.devkuma.kotest.DataDrivenTest
    test calculate
      V TestPattern(num1=1, num2=1, operator=ADD, result=2)
      V TestPattern(num1=3, num2=1, operator=SUBTRACTION, result=2)
      V TestPattern(num1=2, num2=3, operator=MULTIPLICATION, result=6)
      V TestPattern(num1=10, num2=5, operator=DIVIDE, result=2)

각각 테스트 방법을 만드는 것보다 훨씬 간결하게 작성할 수 있다. 실제 프로젝트에서는 이렇게 간단한 입력과 출력이 정의된 함수가 아닌 수도 있지만, 이러한 입력과 출력의 패턴이 여러개라고 생각될 때에는 데이터 구동 테스트를 사용하면 좋을 것이다.

그럼, 위의 caluculate 함수에 테스트 패턴을 하나 추가해 보겠다.

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Number)
        withData(
            TestPattern(1, 1, Operator.ADD, 2),
            TestPattern(3, 1, Operator.SUBTRACTION, 2),
            TestPattern(2, 3, Operator.MULTIPLICATION, 6),
            TestPattern(10, 5, Operator.DIVIDE, 2),
+           TestPattern(5, 0, Operator.DIVIDE, 0) // by zero
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

이렇게 실행하면 0으로 나누게 되므로 ArithmeticException 예외가 발생한다.

/ by zero
java.lang.ArithmeticException: / by zero
	at com.devkuma.kotest.DataDrivenTestKt.calculate(DataDrivenTest.kt:16)
	at com.devkuma.kotest.DataDrivenTest$1$1$1.invokeSuspend(DataDrivenTest.kt:30)

테스트 케이스 명명

기본적으로 데이터 클래스가 toString()이 테스트 이름으로 표시된다. 이 표시를 바꾸고 싶은 경우에는 몇 가지 방법이 있는데, 우선 WithDataTestName를 데이터 클래스에 상속하는 것으로 표시를 변경할 수 있다.

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Int) : WithDataTestName {
            override fun dataTestName(): String =
                "when num1: $num1 num2: $num2 operator: $operator, result is $result"
        }
        withData(
            TestPattern(1, 1, Operator.ADD, 2),
            TestPattern(3, 1, Operator.SUBTRACTION, 2),
            TestPattern(2, 3, Operator.MULTIPLICATION, 6),
            TestPattern(10, 5, Operator.DIVIDE, 2),
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

실행하면 다음과 같이 표시된다.

Test Results
  com.devkuma.kotest.DataDrivenTest
    test calculate
      V when num1: 1 num2: 1 operator: ADD, result is 2
      V when num1: 3 num2: 1 operator: SUBTRACTION, result is 2
      V when num1: 2 num2: 3 operator: MULTIPLICATION, result is 6
      V when num1: 10 num2: 5 operator: DIVIDE, result is 2

이는 다음과 같이 작성 수도 있다.

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Int)
        withData(
            nameFn = { "when num1: ${it.num1} num2: ${it.num2} operator: ${it.operator}, result is ${it.result}" },
            TestPattern(1, 1, Operator.ADD, 2),
            TestPattern(3, 1, Operator.SUBTRACTION, 2),
            TestPattern(2, 3, Operator.MULTIPLICATION, 6),
            TestPattern(10, 5, Operator.DIVIDE, 2),
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

그리고 map을 사용하여 key에 테스트 이름을 지정할 수 있다.

internal class DataDrivenTest : FunSpec({
    context("test calculate") {
        data class TestPattern(val num1: Int, val num2: Int, val operator: Operator, val result: Int)
        withData(
            mapOf(
                "1 + 1 = 2" to TestPattern(1, 1, Operator.ADD, 2),
                "3 - 1 = 2" to TestPattern(3, 1, Operator.SUBTRACTION, 2),
                "2 x 3 = 6" to TestPattern(2, 3, Operator.MULTIPLICATION, 6),
                "10 / 5 = 2" to TestPattern(10, 5, Operator.DIVIDE, 2)
            )
        ) { (num1, num2, operator, result) ->
            calculate(num1, num2, operator) shouldBe result
        }
    }
})

실행하면 다음과 같이 표시된다.

Test Results
  com.devkuma.kotest.DataDrivenTest2
    test calculate
    V 1 + 1 = 2
    V 3 - 1 = 2
    V 2 x 3 = 6
    V 10 / 5 = 2

참조




최종 수정 : 2023-07-19