Kotestライフサイクルフック(Lifecycle Hooks)

効果的なテストコードを書くうえで、各テストケースのライフサイクルを管理することは重要である。ここではライフサイクルフックの使い方を説明する。

テストライフサイクル管理

テストライフサイクル管理とは、各テストケースの実行前後に必要な作業を行うことを意味する。KotestはbeforeTestafterTestブロックを提供しており、テストライフサイクルを管理できる。これにより、各テストケースの実行前後に必要な設定や後処理を行える。

ライフサイクルフック

各テストケースの実行前後に何らかの作業が必要になることがある。たとえば、データベース接続の設定や後処理、テスト時間の計測など、必要な設定または後処理が考えられる。このとき使うのがライフサイクルフック(Lifecycle Hooks)である。

Kotestは、Spec内で直接定義できるさまざまな種類のフックを提供している。配布可能なプラグインや再利用可能なフックの作成など、より高度な場合には拡張(Extensions)を使用できる。

このセクションの最後には、使用可能なフックの一覧と実行されるタイミングを示している。

Kotestでフックを使う方法はいくつかある。

DSLメソッドで作成する

最初のもっとも簡単な方法は、Spec内で使用できるDSLメソッドを使ってTestListenerを生成し登録することである。たとえば、次のようにbeforeTestafterTestを直接呼び出せる。

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を使用できるが、フレームワークがSpecを検出する段階ではすでにプロジェクトが開始されているため、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!

Specでコールバック関数を再定義する

2つ目の方法は、Specでコールバック関数を再定義することである。以下の例は1つ目の方法を変形したものである。

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 特定のコンテナまたはSpec内のすべてのテストが実行される前に呼び出されるコールバックである。特定のコンテナやSpec内のすべてのテストに共通する設定作業を行いたい場合に使う。
afterAny 特定のコンテナまたはSpec内のすべてのテストが実行された後に呼び出されるコールバックである。特定のコンテナやSpec内のすべてのテストに共通する後処理を行いたい場合に使う。
beforeTest 各テストメソッド(Test method)の実行前に呼び出されるコールバックである。この関数を使って各テストの設定を行える。
beforeAnyと同じ動作をする。
afterTest 各テストメソッド(Test method)の実行後に呼び出されるコールバックである。この関数を使って各テストの後処理を行える。
afterAnyと同じ動作をする。
beforeSpec 各Specの実行前に呼び出されるコールバックである。Spec全体に対する設定作業を行いたい場合に使う。
たとえば、データベース接続を設定したり、特定のリソースを初期化したりできる。
IsolationModeSingleInstanceの場合、prepareSpecと同じ動作をする。
afterSpec 各Specの実行後に呼び出されるコールバックである。Spec全体に対する後処理を行いたい場合に使う。
たとえば、データベース接続を閉じたり、特定のリソースを整理したりできる。
IsolationModeSingleInstanceの場合、finalizeSpecと同じ動作をする。このCallbackで例外が発生すると、その後のbeforeSpecAfterSpecはスキップされる。
prepareSpec Specが実行される前に呼び出されるコールバックで、ユーザー定義の初期化作業を行うために使われる。
IsolationModeがSingleInstanceの場合、beforeSpecと同じ動作をする。
finalizeSpec Specが実行された後に呼び出されるコールバックで、ユーザー定義の後処理を行うために使われる。
IsolationModeSingleInstanceの場合、afterSpecと同じ動作をする。
beforeInvocation 各テストメソッド(Test method)またはテストケース(Test case)の実行前に呼び出されるコールバックである。
invocation設定がない場合はbeforeTestと同じ動作をする。
afterInvocation 各テストメソッド(Test method)またはテストケース(Test case)の実行後に呼び出されるコールバックである。
invocation設定がない場合はafterTestと同じ動作をする。

これらのコールバックは、各テストの実行前後やSpecの実行前後など、さまざまな時点で特定の処理を行えるようにする。各コールバックは対応するタイミングで呼び出され、必要に応じて適切なロジックを実装して使用できる。

beforeAnybeforeTestは同じ関数の別名にすぎないが、beforeEachとは異なる。beforeAnybeforeTestはそれぞれTestType.Testの前に呼び出される一方、beforeEachTestType.ContainerTestType.Testの前に呼び出される。afterAnyafterTestafterEachについても同様である。

次のコールバック関数がいつ実行されるかを例で確認する。

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

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

class CallbackTest : StringSpec({

    // 各Specが実行される前に呼び出されるコールバック
    beforeSpec {
        println("beforeSpec: Spec実行前の設定を行う。")
        // Spec全体に対する設定作業を追加する。
    }

    // 各Specが実行された後に呼び出されるコールバック
    afterSpec {
        println("afterSpec: Spec実行後の後処理を行う。")
        // Spec全体に対する後処理を追加する。
    }

    // 各テスト実行前に呼び出されるコールバック
    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: Spec実行前の設定を行う。
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: Spec実行後の後処理を行う。

参考