Kotest 라이프사이클 후크(Lifecycle Hooks)

효과적인 테스트 코드를 작성하는데 있어서 각 테스트 케이스의 라이프사이클을 관리하는 것이 중요한다. 여기서는 라이프사이클 후크를 하는 방법에 대해서 설명한다.

테스트 라이프사이클 관리

테스트 라이프사이클 관리는 각 테스트 케이스의 실행 전후에 필요한 작업을 수행하는 것을 의미한다. Kotest는 beforeTestafterTest 블록을 제공하여 테스트 라이프사이클을 관리할 수 있다. 이를 통해 각 테스트 케이스의 실행 전후에 필요한 설정 또는 정리 작업을 수행할 수 있다.

라이프사이클 후크

각 테스트 케이스의 실행 전후에 어떤 작업을 수행해야 하는 경우가 있다. 예를 들어, 데이터베이스 연결을 설정하거나 정리하는 작업, 테스트 시간을 측정 등의 필요한 설정 또는 정리 작업이 있을 수 있다. 이때 사용하는 것이 라이프사이클 후크(Lifecycle Hooks)이다.

Kotest는 스펙 내에서 직접 정의할 수 있는 다양한 종류의 후크를 제공한다. 배포 가능한 플러그인이나 재사용 가능한 후크 작성과 같은 더 발전된 경우의 확장(Extentions)을 사용할 수도 있다.

이 섹션의 마지막에는 사용 가능한 후크 목록과 실행 시기가 나와 있다.

Kotest에서 후크를 사용하는 방법에는 여러 가지가 있다:

DSL 메서드으로 작성하기

첫번째로 가장 간단한 방법은 스펙 내에서 사용 가능한 DSL 메서드를 사용하여 TestListener를 생성하고 등록하는 것이다. 예를 들어, 다음과 같이 beforeTest, afterTest를 직접 호출할 수 있다.

package com.devkuma.kotest.tutorial.lifecycle.ex1

import io.kotest.core.spec.style.WordSpec

class TestSpec : WordSpec({
    beforeTest {
        println("Starting a test $it")
    }

    afterTest { (test, result) ->
        println("Finished spec with result $result")
    }

    "this test" should {
        "be alive" {
            println("devkuma is alive!")
        }
    }
})

다음은 예제를 실행한 결과이다:

Starting a test TestCase(descriptor=TestDescriptor... 생략 ...
Starting a test TestCase(descriptor=TestDescriptor... 생략 ...
Johnny5 is alive!
Finished spec with result Success(duration=6ms)
Finished spec with result Success(duration=51ms)

백그라운드에서 이러한 DSL 메서드는 적절한 함수를 재정의하고, 이 테스트 리스너가 실행되도록 등록한 TestListener의 인스턴스를 생성한다.

프로젝트 리스너의 인스턴스를 생성하는 DSL 메서드로 afterProject를 사용할 수 있지만, 프레임워크가 스펙을 감지하는 이 단계에 있을 때는 이미 프로젝트가 시작되었으므로 beforeProject는 없다!

함수를 사용하는 DSL 메서드

이러한 DSL 메서드는 함수를 받으므로, 함수에 대한 로직을 가져와 여러 곳에서 재사용할 수 있다.

package com.devkuma.kotest.tutorial.lifecycle.ex2

import io.kotest.core.spec.style.WordSpec
import io.kotest.core.test.TestCase

val startTest: suspend (TestCase) -> Unit = {
    println("Starting a test $it")
}

class TestSpec : WordSpec({

    // used once
    beforeTest(startTest)

    "this test" should {
        "be alive" {
            println("devkuma is alive!")
        }
    }
})

class OtherSpec : WordSpec({

    // used twice
    beforeTest(startTest)

    "this test" should {
        "fail" {
            println("boom")
        }
    }
})

다음은 예제를 실행한 결과이다:

Starting a test TestCase(descriptor=TestDescriptor... 생략 ...
boom
Starting a test TestCase(descriptor=TestDescriptor... 생략 ...
devkuma is alive!

스펙에서 콜백 함수 재정의하기

두 번째 방법으로는 스펙에서 콜백 함수를 재정의하는 것이다. 아래 예제는 첫 번째 방법을 변형하였다.

package com.devkuma.kotest.tutorial.lifecycle.ex3

import io.kotest.core.spec.style.WordSpec
import io.kotest.core.test.TestCase
class TestSpec : WordSpec() {

    override suspend fun beforeTest(testCase: TestCase) {
        println("Starting a test $testCase")
    }

    init {
        "this test" should {
            "be alive" {
  println("devkuma is alive!")
            }
        }
    }
}

라이프사이클 콜백

모든 콜백을 올바르게 이해하려면, 가능한 TestType 값을 이해하는 것이 중요하다:

  • Container: 다른 테스트를 포함할 수 있는 컨테이너
  • Test: 중첩된 테스트를 포함할 수 없는 리프(Leaf) 테스트. 즉, 다른 테스트를 호출하지 않고 독립적으로 실행될 수 있는 테스트
  • Dynamic: 컨테이너 또는 테스트가 될 수 있으며, 속성 테스트 또는 데이터 테스트와 같은 기능을 통해 테스트가 동적으로 추가될 때 사용

Kotest에서는 테스트 수명주기에 따라 실행되는 여러 종류의 콜백 함수를 제공한다.

아래는 Kotest에서 사용되는 각 콜백에 대한 설명이다:

콜백 설명
beforeContainer 컨테이너(Test container) 내의 모든 테스트가 실행되기 전에 호출되는 콜백이다.
afterContainer 컨테이너(Test container) 내의 모든 테스트가 실행된 후에 호출되는 콜백이다.
beforeEach 각 테스트 케이스(Test case) 실행 전에 호출되는 콜백이다. 각 테스트마다 별도의 설정을 수행하고 싶을 때 사용된다.
afterEach 각 테스트 케이스(Test case) 실행 후에 호출되는 콜백이다. 각 테스트마다 별도의 정리 작업을 수행하고 싶을 때 사용된다.
beforeAny 특정 컨테이너 또는 스펙 내의 모든 테스트가 실행되기 전에 호출되는 콜백이다. 특정 컨테이너나 스펙 내의 모든 테스트에 대한 공통적인 설정 작업을 수행하고자 할 때 사용된다.
afterAny 특정 컨테이너 또는 스펙 내의 모든 테스트가 실행된 후에 호출되는 콜백이다. 특정 컨테이너나 스펙 내의 모든 테스트에 대한 공통적인 정리 작업을 수행하고자 할 때 사용된다.
beforeTest 각 테스트 메소드(Test method) 실행 전에 호출되는 콜백이다. 이 함수를 사용하여 각 테스트의 설정을 수행할 수 있다.
beforeAny와 동일한 동작을 한다.
afterTest 각 테스트 메소드(Test method) 실행 후에 호출되는 콜백이다. 이 함수를 사용하여 각 테스트의 정리 작업을 수행할 수 있다.
afterAny와 동일한 동작을 한다.
beforeSpec 각 스펙(Spec) 실행 전에 호출되는 콜백이다. 스펙 전체에 대한 설정 작업을 수행하고자 할 때 사용된다.
예를 들어, 데이터베이스 연결을 설정하거나 특정 자원을 초기화하는 작업을 수행할 수 있다.
IsolationModeSingleInstance인 경우, prepareSpec과 같은 동작을 한다.
afterSpec 각 스펙(Spec) 실행 후에 호출되는 콜백이다. 스펙 전체에 대한 정리 작업을 수행하고자 할 때 사용된다.
예를 들어, 데이터베이스 연결을 닫거나 특정 자원을 정리하는 작업을 수행할 수 있다.
IsolationMode SingleInstance인 경우, finalizeSpec과 같은 동작을 한다. 해당 Callback에서 예외가 발생하면 이후 beforeSpec, AfterSpec은 스킵된다.
prepareSpec 스펙(Spec)이 실행되기 전에 호출되는 콜백으로, 사용자 정의 초기화 작업을 수행하는 데 사용된다.
IsolationMode가 SingleInstance인 경우, beforeSpec과 같은 동작을 한다.
finalizeSpec 스펙(Spec)이 실행된 후에 호출되는 콜백으로, 사용자 정의 정리 작업을 수행하는 데 사용된다.
IsolationModeSingleInstance인 경우, afterSpec과 같은 동작을 한다.
beforeInvocation 각 테스트 메소드(Test method) 또는 테스트 케이스(Test case) 실행 전에 호출되는 콜백이다.
invocation 설정이 없으면 beforeTest와 같은 동작을 한다.
afterInvocation 각 테스트 메소드(Test method) 또는 테스트 케이스(Test case) 실행 후에 호출되는 콜백이다.
invocation 설정이 없으면 afterTest와 같은 동작을 한다.

이러한 콜백들은 각 테스트 실행 전후나 스펙 실행 전후 등의 다양한 시점에 특정 동작을 수행할 수 있도록 도와준다. 각 콜백은 해당하는 시점에 호출되며, 필요에 따라 적절한 로직을 구현하여 사용할 수 있다.

beforeAnybeforeTest는 동일한 함수의 다른 이름일 뿐이지만, beforeEach와는 다르다. beforeAny, beforeTest 각각 TestType.Test 전에 호출되는 반면, beforeEachTestType.ContainerTestType.Test 전에 호출된다. afterAny, afterTestafterEach에도 동일하게 적용된다.

다음 콜백 함수가 언제 실행되는 알수 있는 예시를 통해서 알아보겠다:

package com.devkuma.kotest.tutorial.lifecycle.ex4

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

class CallbackTest : StringSpec({

    // 각 스펙이 실행되기 전에 호출되는 콜백
    beforeSpec {
        println("beforeSpec: 스펙 실행 전 설정을 수행.")
        // 스펙 전체에 대한 설정 작업을 추가.
    }

    // 각 스펙이 실행된 후에 호출되는 콜백
    afterSpec {
        println("afterSpec: 스펙 실행 후 정리를 수행.")
        // 스펙 전체에 대한 정리 작업을 추가.
    }

    // 각 테스트 실행 전에 호출되는 콜백
    beforeTest { testCase ->
        println("beforeTest: ${testCase.name.testName} 실행 전 설정을 수행.")
        // 여기에 테스트의 공통 설정 작업을 추가.
    }

    // 각 테스트 실행 후에 호출되는 콜백
    afterTest { (testCase, testResult) ->
        println("afterTest: ${testCase.name.testName} 실행 후 정리를 수행.")
        println("결과: $testResult")
        // 여기에 테스트의 공통 정리 작업을 추가.
    }

    // 모든 테스트가 실행되기 전에 호출되는 콜백
    beforeAny { testCase ->
        println("beforeAny: ${testCase.name.testName} 테스트 실행 전 설정을 수행.")
        // 여기에 모든 테스트에 대한 공통 설정 작업을 추가다.
    }

    // 모든 테스트가 실행된 후에 호출되는 콜백
    afterAny { (testCase, testResult) ->
        println("afterAny:${testCase.name.testName} 테스트 실행 후 정리를 수행.")
        println("결과: $testResult")
        // 여기에 모든 테스트에 대한 공통 정리 작업을 추가.
    }

    // 각 테스트 실행 전에 호출되는 콜백
    beforeEach { testCase ->
        println("beforeEach: ${testCase.name.testName} 테스트 실행 전 설정을 수행.")
        // 여기에 테스트의 개별 설정 작업을 추가.
    }

    // 각 테스트 실행 후에 호출되는 콜백
    afterEach { (testCase, testResult) ->
        println("afterEach: ${testCase.name.testName} 테스트 실행 후 정리를 수행.")
        println("결과: $testResult")
        // 여기에 테스트의 개별 정리 작업을 추가.
    }

    // 테스트 케이스 1
    "TestCase1" {
        println("TestCase1 실행.")
        val result = 2 + 2
        result shouldBe 4
    }

    // 테스트 케이스 2
    "TestCase2" {
        println("'TestCase2' 실행.")
        val list = mutableListOf<Int>()
        list.add(1)
        list.add(2)
        list.size shouldBe 2
    }
})

다음은 위 예시를 실행한 결과이다:

beforeSpec: 스펙 실행 전 설정을 수행.
beforeEach: TestCase1 테스트 실행 전 설정을 수행.
beforeTest: TestCase1 실행 전 설정을 수행.
beforeAny: TestCase1 테스트 실행 전 설정을 수행.
TestCase1 실행.
afterAny:TestCase1 테스트 실행 후 정리를 수행. 결과: Success(duration=36ms)
afterTest: TestCase1 실행 후 정리를 수행. 결과: Success(duration=36ms)
afterEach: TestCase1 테스트 실행 후 정리를 수행. 결과: Success(duration=36ms)
beforeEach: TestCase2 테스트 실행 전 설정을 수행.
beforeTest: TestCase2 실행 전 설정을 수행.
beforeAny: TestCase2 테스트 실행 전 설정을 수행.
'TestCase2' 실행.
afterAny:TestCase2 테스트 실행 후 정리를 수행. 결과: Success(duration=1ms)
afterTest: TestCase2 실행 후 정리를 수행. 결과: Success(duration=1ms)
afterEach: TestCase2 테스트 실행 후 정리를 수행. 결과: Success(duration=1ms)
afterSpec: 스펙 실행 후 정리를 수행.

참고




최종 수정 : 2024-04-24