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

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

테스트 라이프사이클 관리

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

라이프사이클 후크

각 테스트 케이스의 실행 전후에 어떤 작업을 수행해야 하는 경우가 있다. 예를 들어, 데이터베이스 연결을 설정하거나 정리하는 작업, 테스트 시간을 측정 등의 필요한 설정 또는 정리 작업이 있을 수 있다. 이때 사용하는 것이 라이프사이클 후크(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) 실행 전에 호출되는 콜백이다. 스펙 전체에 대한 설정 작업을 수행하고자 할 때 사용된다.
예를 들어, 데이터베이스 연결을 설정하거나 특정 자원을 초기화하는 작업을 수행할 수 있다.
IsolationMode가 SingleInstance인 경우, prepareSpec과 같은 동작을 한다.
afterSpec 각 스펙(Spec) 실행 후에 호출되는 콜백이다. 스펙 전체에 대한 정리 작업을 수행하고자 할 때 사용된다.
예를 들어, 데이터베이스 연결을 닫거나 특정 자원을 정리하는 작업을 수행할 수 있다.
IsolationMode가 SingleInstance인 경우, finalizeSpec과 같은 동작을 한다. 해당 Callback에서 예외가 발생하면 이후 beforeSpec, AfterSpec은 스킵된다.
prepareSpec 스펙(Spec)이 실행되기 전에 호출되는 콜백으로, 사용자 정의 초기화 작업을 수행하는 데 사용된다.
IsolationMode가 SingleInstance인 경우, beforeSpec과 같은 동작을 한다.
finalizeSpec 스펙(Spec)이 실행된 후에 호출되는 콜백으로, 사용자 정의 정리 작업을 수행하는 데 사용된다.
IsolationMode가 SingleInstance인 경우, afterSpec과 같은 동작을 한다.
beforeInvocation 각 테스트 메소드(Test method) 또는 테스트 케이스(Test case) 실행 전에 호출되는 콜백이다.
invocation 설정이 없으면 beforeTest와 같은 동작을 한다.
afterInvocation 각 테스트 메소드(Test method) 또는 테스트 케이스(Test case) 실행 후에 호출되는 콜백이다.
invocation 설정이 없으면 afterTest와 같은 동작을 한다.

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

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

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

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: 스펙 실행 후 정리를 수행.

참고