📘 Backend/Kotlin

Kotest - Basic

신건우 2023. 5. 18. 19:45

Kotest Basic


Settings

build.gradle

testImplementation("io.kotest:kotest-runner-junit5:5.3.2")  
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.1")

plugins

  • Kotest

Spec

코틀린에는 테스트 레이아웃이 10개정도 있는데 이 중 하나를 상속받아 진행한다.

여러 테스트 프레임워크에서 영향을 받아 만들어진 것도 있고, 코틀린만을 위해 만들어진 것도 있다.

Spec

어떤 스타일을 고르던 기능적 차이는 없다.

취향에 따라, 혹은 팀의 스타일에 따라 고르면 될 듯 하다.

ex) FreeSpec

internal class HumanTest: FreeSpec() {}

AnnotationSpec

JUnit과 가장 유사한 테스트 스타일이다. JUnit -> kotest로 마이그레이션할 때 가장 변경이 적은 테스트 스타일이긴 하다.

그렇지만 NON-ASCII 경고가 발생하기도 하고, StringSpec이라는 대체제도 있으므로 개인적으론 StringSpec을 쓰는 게 더 나아보인다.

import io.kotest.core.spec.style.AnnotationSpec  

class AnnotationSpec : AnnotationSpec() {  
    @Test  
    fun `회원의 비밀번호와 일치하는지 확인한다()`{  
        val user = createUser()  
        shouldNotThrowAny { user.authenticate(PASSWORD) }  
    }  

    @Test  
    fun `회원의 비밀번호와 다를 경우 예외가 발생한다`() {  
        val user = createUser()  
        shouldThrow<UnidentifiedUserException> { user.authenticate(WRONG_PASSWORD) }  
    }  
}

StringSpec

@Test라는 어노테이션을 붙이지 않아도 되고, fun 키워드 없이 바로 테스트명을 String으로 지을 수 있다는 점에서 매력적이라 느껴진다. 한글로 작성해도 인텔리제이에서 노란줄을 띄워주지 않아 편안하다.

StringSpec은 AnnotationSpec과 같이 괄호 위치가 ({ ... })인 점에 유의하자.

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

class UserTest : StringSpec({  
    "회원의 비밀번호와 일치하는지 확인한다" {  
        val user = createUser()  
        shouldNotThrowAny { user.authenticate(PASSWORD) }  
    }  
    "회원의 비밀번호와 다를 경우 예외가 발생한다" {  
        val user = createUser()  
        shouldThrow<UnidentifiedUserException> { user.authenticate(WRONG_PASSWORD) }  
    }})

BehaviorSpec

Given-When-Then 테스트 패턴을 쓰고 싶을 때 사용한다.

Given When Then을 소문자로 작성하지 않도록 주의하자.

소문자로 작성할 경우 given, then은 상관없지만 when은 백틱을 쳐주도록 하자. (given when then)

참고로 slot은 mockK 관련 키워드이다. mockito 대신 mockK를 이용하면 더 코틀린스러운 테스트를 작성 가능하다.

import io.kotest.core.spec.style.BehaviorSpec  

internal class UserServiceTest : BehaviorSpec({  
    val userRepository: UserRepository = mockk()  
    val passwordGenerator: PasswordGenerator = mockk()  

    Given("유저의 비밀번호가 주어질 때") {  
        When("비밀번호를 변경하려 하면") {  
            var request: EditPasswordRequest = mockk()  

            slot<Long>().also { slot ->  
                every { userRepository.getById(capture(slot)) } answers { createUser(id = slot.captured) }  
            }  
            Then("확인용 비밀번호가 일치한다면 변경한다") {  
                // ...  
            }  

            Then("확인용 비밀번호가 일치하지 않으면 예외가 발생한다") {  
                // ...  
            }  
        }    }})

DescribeSpec

DCI 패턴을 쓰고 싶을 때 사용한다.

Given When Then 패턴이 아닌 DCI 패턴을 사용하고 싶을 경우 DescribeSpec을 사용하면 된다.

BehaviorSpec의 Given-When-Then 패턴과 DescribeSpec의 DCI 패턴은 테스트 중첩 (Given 안에 When 여러 개, When 안에 Then 여러 개처럼)이 가능하다는 장점이 있다.
이는 중복을 제거하기에 좋다.

import io.kotest.core.spec.style.DescribeSpec  

internal class UserServiceTest : DescribeSpec({  
    val userRepository: UserRepository = mockk()  

    describe("UserService") {  
        var user: User = createUser()  
        var request: ResetPasswordRequest = mockk()  

        context("비밀번호를 비교할 때") {  
            var request: EditPasswordRequest = mockk()  
            slot<Long>().also { slot ->  
                every { userRepository.getById(capture(slot)) } answers { createUser(id = slot.captured) }  
            }            // ...  

            it("확인용 비밀번호가 일치한다면 변경한다") {  
                // ...  
            }  

            it("확인용 비밀번호가 일치하지 않으면 예외가 발생한다") {  
                // ...  
            }  
        }    }})

테스트 코드 작성

아래 테스트 코드는 FreeSpec 기준으로 작성한다.


전후 처리

기존 @BeforeEach, @BeforeAll, @AfterEach 등과 같은 전후처리를 위한 기본 어노테이션을 사용하지 않는다.
각 Spec의 SpecFunctionCallbacks 인터페이스에 의해 override하여 구현한다.


interface SpecFunctionCallbacks {
   fun beforeSpec(spec: Spec) {}
   fun afterSpec(spec: Spec) {}
   fun beforeTest(testCase: TestCase) {}
   fun afterTest(testCase: TestCase, result: TestResult) {}
   fun beforeContainer(testCase: TestCase) {}
   fun afterContainer(testCase: TestCase, result: TestResult) {}
   fun beforeEach(testCase: TestCase) {}
   fun afterEach(testCase: TestCase, result: TestResult) {}
   fun beforeAny(testCase: TestCase) {}
   fun afterAny(testCase: TestCase, result: TestResult) {}
}

위 인터페이스를 참고하여 작성하면 아래와 같이 사용할 수 있다.

internal class HumanTest : FreeSpec() {

    override fun beforeSpec(spec: Spec) {
        println("beforeSpec")
    }

    override fun beforeTest(testCase: TestCase) {
        println("beforeTest")
    }

    override fun beforeContainer(testCase: TestCase) {
        println("beforeContainer")
    }

    override fun beforeEach(testCase: TestCase) {
        println("beforeEach")
    }

    override fun beforeAny(testCase: TestCase) {
        println("beforeAny")
    }

    init {
        "그냥 컨테이너" - {
            "그냥 테스트1" {
                println("그냥 테스트1")
                "".length shouldBe 0
            }
            "그냥 테스트2" {
                println("그냥 테스트2")
                "12345".length shouldBe 5
            }
        }
    }
}

실행 결과, 결과를 보면 각 Function들이 어느 시점에 실행되는지 확인 가능하다.

## 실행결과

beforeSpec

beforeContainer
beforeAny
beforeTest

beforeEach
beforeAny
beforeTest
그냥 테스트1

beforeEach
beforeAny
beforeTest
그냥 테스트2

Assertion 알아보기

kotest는 아주 풍부한 assertion을 제공하는데, 몇가지 assertion 사용법에 대해 알아보자.

Assertions

전부 다 알아보기는 너무 많으니 간단한 예제로 대체한다.

예시

init {
    "Matchers" - {
        val testStr = "I am iron man"
        val testNum = 5
        val testList = listOf<String>("iron", "bronze", "silver")

        "일치 하는지" {
            testStr shouldBe "I am iron man"
        }
        "일치 안 하는지" {
            testStr shouldNotBe "I am silver man"
        }
        "해당 문자열로 시작하는지" {
            testStr shouldStartWith "I am"
        }
        "해당 문자열을 포함하는지" {
            testStr shouldContain "iron"
        }
        "리스트에서 해당 리스트의 값들이 모두 포함되는지" {
            testList shouldContainAll listOf("iron", "silver")
        }
        "대소문자 무시하고 일치하는지" {
            testStr shouldBeEqualIgnoringCase "I AM IRON MAN"
        }
        "보다 큰거나 같은지" {
            testNum shouldBeGreaterThanOrEqualTo 3
        }
        "해당 문자열과 길이가 같은지" {
            testStr shouldHaveSameLengthAs "I AM SUPERMAN"
        }
        "문자열 길이" {
            testStr shouldHaveLength 13
        }
        "여러개 체이닝" {
            testStr.shouldStartWith("I").shouldHaveLength(13).shouldContainIgnoringCase("IRON")
        }
    }
}

Exception 발생 체크

"Exception" - {
    "ArithmeticException Exception 발생하는지" {
        val exception = shouldThrow<ArithmeticException> {
            1 / 0
        }
        exception.message shouldStartWith("/ by zero")
    }
    "어떤 Exception이든 발생하는지" {
        val exception = shouldThrowAny {
            1 / 0
        }
        exception.message shouldStartWith("/ by zero")
    }
}

Clues를 이용한 에러 추적

테스트 중이나 테스트가 실패했을때 더 자세한 단서를 남길 수 있다.

"Clues" - {
    data class HttpResponse(val status: Int, val body: String)
    val response = HttpResponse(404, "the content")

    "Not Use Clues" {
        response.status shouldBe 200
        response.body shouldBe "the content"
        // 결과: expected:<200> but was:<404>
    }
    "With Clues" {
        withClue("status는 200이여야 되고 body는 'the content'여야 한다") {
            response.status shouldBe 200
            response.body shouldBe "the content"
        }
        // 결과: status는 200이여야 되고 body는 'the content'여야 한다
    }
    "As Clues" {
        response.asClue {
            it.status shouldBe 200
            it.body shouldBe "the content"
        }
        // 결과: HttpResponse(status=404, body=the content)
    }
}

Soft Assertion

Sort Assertion을 사용하면 중간에 asert가 실패해도 assertAll 처럼 끝까지 체크가 가능하다.

"Soft Assertions" - {
    val testStr = "I am iron man"
    val testNum = 5

    "Not Soft" {
        testStr shouldBe "IronMan"
        testNum shouldBe 1
        // 결과: expected:<"IronMan"> but was:<"I am iron man">
    }
    "Use Soft" {
        assertSoftly {
            testStr shouldBe "IronMan"
            testNum shouldBe 1
        }
        // 결과: expected:<"IronMan"> but was:<"I am iron man">
        //      expected:<1> but was:<5>
    }
}

Data Driven Testing

아래 기능을 이용해서 다른 매개변수를 정의하여 각각 테스트가 가능하다.

이렇게 데이터를 세팅하고, 각 행별로 테스트가 가능하다.

data test" - {
    "forAll" {
        forAll(
            row("haha", 13),
            row("hoho", 22),
        ) { name, age ->
            name.length shouldBe 4
            age shouldBeGreaterThanOrEqualTo 10
        }
    }
    "table forAll" {
        table(
            headers("name", "age"),
            row("haha", 13),
            row("hoho", 22)
        ).forAll { name, age ->
            name.length shouldBe 4
            age shouldBeGreaterThanOrEqualTo 10
        }
    }
    "collection" {
        listOf(
            row("haha", 13),
            row("hoho", 22)
        ).map { (name: String, age: Int) ->
            name.length shouldBe 4
            age shouldBeGreaterThanOrEqualTo 10
        }
    }
}