Kotestデータ駆動テスト(Data-Driven Testing)

効果的なテストには、テストデータの生成、管理、整理が重要である。このセクションでは、Kotestを使ってテストデータをどのように管理するかを見ていく。

データ駆動テスト

データ駆動テスト(Data-Driven Testing)は、同じテストコードを複数のデータセットに対して繰り返し実行する方法で、JUnit 5のパラメータ化(Parameterized)テストに似ている。異なる入力データごとに複数のテストを書く代わりに、1つのテストケースへ複数の入力を渡して複数のケースを検証できる。

ロジックベースのテストを書く場合、特定のシナリオで動作する1つか2つの特定コードパスで十分なことがある。一方で、例ベースのテストが多く、さまざまなパラメータの組み合わせをテストすると役立つ場合もある。

このような状況で、データ駆動テスト、つまりテーブル駆動テストは、退屈な定型コードを避けるための簡単な手法である。

Kotestは、フレームワークに組み込まれたデータ中心テストを第一級の機能として提供している。これはユーザーが提供した入力値をもとに、テストケース項目を自動生成する。

データ駆動テストを始める

簡単な例を通じてデータ駆動テストを見てみよう。

ピタゴラスの三角形テスト

入力値が有効な三角形、つまりaの2乗 + bの2乗 = cの2乗である場合にtrueを返すピタゴラスの三角形関数のテストを書く。

fun isPythagTriple(a: Int, b: Int, c: Int): Boolean = a * a + b * b == c * c

1行(Row)には1つ以上の要素が必要であり、ここでは3つ必要なので、まず1行分の値、この場合は2つの入力と期待結果を保持するデータクラスを定義する。

data class PythagTriple(val a: Int, val b: Int, val c: Int)

このデータクラスのインスタンスを使ってテストを生成し、各行のテストロジックを実行するラムダを受け取るwithData関数に渡す。

例:

package com.devkuma.kotest.tutorial.datadriven

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

class PythagTripleTest : FunSpec({
    context("Pythag triples tests") {
        withData(
            PythagTriple(3, 4, 5),
            PythagTriple(6, 8, 10),
            PythagTriple(8, 15, 17),
            PythagTriple(7, 24, 25)
        ) { (a, b, c) ->
            isPythagTriple(a, b, c) shouldBe true
        }
    }
})

データクラスを使用しているため、入力rowをメンバー属性として分解できる。これを実行すると、入力行ごとに1つ、合計4つのテストケースが生成される。

Kotestは、各入力行に対して個別のテストケースを手動で作成したかのように、各入力行に対するテストケースを自動生成する。

Data-Driven Testing

テスト名はデータクラス自体から生成されるが、カスタマイズもできる。

特定の入力行にエラーがある場合、テストは失敗し、Kotestは失敗した値を出力する。たとえば、前の例にPythagTriple(5, 4, 3)行を含めるよう変更すると、そのテストは失敗として表示される。

Data-Driven Testing

エラーメッセージにはエラーと入力行の詳細が含まれる。

expected:<true> but was:<false>
Expected :true
Actual   :false

詳しく見ると、a=5, b=4, c=3のとき、5 * 5 + 4 * 4 = 413 * 3 = 9は等しくないため、falseが返されて失敗する。

前の例では、親テストでwithData呼び出しをラップしているため、テスト結果が表示されるときに追加コンテキスト(context("Pythag triples tests"))がある。構文は使用するSpecスタイルによって異なる。ここではコンテナにcontextブロックを使うFunSpecを使用した。実際には、データテストはいくらでもコンテナ内にネストできる。

ただし、これは任意であり、ルートレベルでもデータテストを定義できる。

例:

class PythagTripleTest : FunSpec({
    withData(
        PythagTriple(3, 4, 5),
        PythagTriple(6, 8, 10),
        //PythagTriple(5, 4, 3),
        PythagTriple(8, 15, 17),
        PythagTriple(7, 24, 25)
    ) { (a, b, c) ->
        isPythagTriple(a, b, c) shouldBe true
    }
})

さまざまな値に対するテスト

別の例として、さまざまな型のデータをテストする例を以下に示す。

package com.devkuma.kotest.tutorial.datadriven

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

class WithDataTest : FunSpec({

    context("文字列長の検証") {
        withData(
            ValueExpected("Hello", 5), // 1つ目のデータセット: 文字列 "Hello"
            ValueExpected("World", 5)  // 2つ目のデータセット: 文字列 "World"
        ) { (str, expected) ->
            str.length shouldBe expected // 各データセットについて文字列の長さが期待値か確認
        }
    }

    context("集合サイズの検証") {
        withData(
            ValueExpected(setOf(1, 2, 3), 3),   // 1つ目のデータセット: 整数集合 {1, 2, 3}
            ValueExpected(setOf(4, 5, 6, 7), 4) // 2つ目のデータセット: 整数集合 {4, 5, 6, 7}
        ) { (set, expected) ->
            set.size shouldBe expected // 各データセットについて集合のサイズが期待値か確認
        }
    }

    context("マップサイズの検証") {
        withData(
            ValueExpected(mapOf("a" to 1, "b" to 2), 2),             // 1つ目のデータセット: マップ {"a" -> 1, "b" -> 2}
            ValueExpected(mapOf("x" to 10, "y" to 20, "z" to 30), 3) // 2つ目のデータセット: マップ {"x" -> 10, "y" -> 20, "z" -> 30}
        ) { (map, expected) ->
            map.size shouldBe expected // 各データセットについてマップのサイズが期待値か確認
        }
    }
})

data class ValueExpected<T>(
    val value: T,
    val expected: Int,
)

forAll関数の使用

データ検証のためにforAll関数を使用し、データ駆動テストを行うこともできる。

package com.devkuma.kotest.tutorial.datadriven

import io.kotest.core.spec.style.StringSpec
import io.kotest.data.forAll
import io.kotest.data.row
import io.kotest.matchers.shouldBe

class ForAllTest : StringSpec({

    "文字列長の検証" {
        forAll(
            row("Hello", 5),
            row("World", 5)
        ) { str, expected ->
            str.length shouldBe expected
        }
    }

    "集合サイズの検証" {
        forAll(
            row(setOf(1, 2, 3), 3),
            row(setOf(4, 5, 6, 7), 4)
        ) { set, expected ->
            set.size shouldBe expected
        }
    }

    "マップサイズの検証" {
        forAll(
            row(mapOf("a" to 1, "b" to 2), 2),
            row(mapOf("x" to 10, "y" to 20, "z" to 30), 3)
        ) { map, expected ->
            map.size shouldBe expected
        }
    }
})

上の例では、forAll関数を使って整数データを生成し、そのデータでテストを繰り返し実行している。

Callbacks

データ駆動テストで前後のコールバックを使うには、標準のbeforeTest / afterTestを使用できる。データ駆動テストで作成されたすべてのテストは通常のテストと同じように動作するため、標準コールバックはすべて、各テストを直接書いた場合と同じように動作する。

例:

package com.devkuma.kotest.tutorial.datadriven

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

class CallbackTest : FunSpec({

    beforeTest {
        println("beforeTest")
    }

    context("callback test") {
        withData("X", "Y", "Z") { a ->
            println(a)
        }
    }
})

次は例を実行した結果である。

beforeTest
beforeTest
X
beforeTest
Y
beforeTest
Z

データテスト名

デフォルトでは、各テストの名前は単に入力行のtoString()である。これは通常JVMのデータクラスではうまく機能するが、入力行は安定している必要がある。

しかし、安定したデータクラスを使わない場合、JVM以外のターゲットで実行する場合、または単にカスタマイズしたい場合は、テスト名を生成する方法を指定できる。

安定した名前

テストを生成するとき、Kotestはテストスイートの実行中に安定したテスト名を必要とする。テスト名は、GradleまたはIntelliJにテスト状態を通知するとき、テストを指す識別子の基礎として使われる。名前が安定していないとIDが変わり、テストが表示されなかったり完了していないように見えたりするエラーが起きる可能性がある。

Kotestは、入力クラスのtoString()値が安定していると判断される場合にのみ入力クラスのtoString()を使用し、そうでない場合はクラス名を使用する。

@IsStableTypeで型にアノテーションを追加すると、Kotestにテスト名としてtoString()を使うよう強制できる。この場合、条件に関係なくtoString()が使用される。

または、テストの表示名を完全にカスタマイズできる。

mapOfの使用

Kotestでは、キーがテスト名、値がその行の入力値であるマップをwithData関数に渡すことで、テスト名を指定できる。

package com.devkuma.kotest.tutorial.datadriven

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

class PythagTripleMapOfTest : FunSpec({
    
    context("Pythag triples tests") {
        withData(
            mapOf(
                "3, 4, 5" to PythagTriple(3, 4, 5),
                "6, 8, 10" to PythagTriple(6, 8, 10),
                "8, 15, 17" to PythagTriple(8, 15, 17),
                "7, 24, 25" to PythagTriple(7, 24, 25)
            )
        ) { (a, b, c) ->
            a * a + b * b shouldBe c * c
        }
    }
})

テスト名関数

または、行を入力として受け取りテスト名を返す関数をwithDataに渡すこともできる。Kotlinの型推論がどの程度うまく働くかによって、withData関数に型パラメータを指定する必要がある場合もある。

package com.devkuma.kotest.tutorial.datadriven

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

class PythagTripleNameTest : FunSpec({

    context("Pythag triples tests") {
        withData<PythagTriple>(
            nameFn = { "${it.a}, ${it.b}, ${it.c}" },
            PythagTriple(3, 4, 5),
            PythagTriple(6, 8, 10),
            PythagTriple(8, 15, 17),
            PythagTriple(7, 24, 25)
        ) { (a, b, c) ->
            a * a + b * b shouldBe c * c
        }
    }
})

この例の出力は少し分かりやすくなる。

Data-Driven Testing

WithDataTestName

もう1つの方法は、WithDataTestNameインターフェースを実装することである。このインターフェースを提供するとtoString()は使用されず、代わりに各行に対してそのインターフェースのdataTestName()関数が呼び出される。

package com.devkuma.kotest.tutorial.datadriven

import io.kotest.datatest.WithDataTestName

data class PythagTriple(val a: Int, val b: Int, val c: Int) : WithDataTestName {
    override fun dataTestName() = "wibble $a, $b, $c wobble"
}

この例の出力はより簡単になる。

Data-Driven Testing

ネストされたデータテスト

Kotestのデータテストは非常に柔軟で、データテスト構成を無制限にネストできる。追加のネストごとにテスト出力に別のネスト層が生成され、すべての入力のCartesian joinを提供する。

たとえば、次のコードスニペットには2つのネスト層がある。

package com.devkuma.kotest.tutorial.datadriven

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

class NestedDataTest : FunSpec({

    context("each service should support all http methods") {
        val services = listOf(
            "http://internal.foo",
            "http://internal.bar",
            "http://public.baz",
        )

        val methods = listOf("GET", "POST", "PUT")

        withData(services) { service ->
            withData(methods) { method ->
                // test service against method
            }
        }
    }
})

Data-Driven Testing

次は同じ例だが、今回は2番目のレベルにカスタムテスト名を追加した例である。

package com.devkuma.kotest.tutorial.datadriven

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

class NestedDataTest2 : FunSpec({

    context("each service should support all http methods") {

        val services = listOf(
            "http://internal.foo",
            "http://internal.bar",
            "http://public.baz",
        )

        val methods = listOf("GET", "POST", "PUT")

        withData(services) { service ->
            withData<String>({ "should support HTTP $it" }, methods) { method ->
                // test service against method
            }
        }
    }
})

Data-Driven Testing

その他のデータテスト

前ではwithData関数を使ったテストを説明した。ここではKotestでの基本的なデータ活用方法を説明する。

テストデータの生成

テストデータを生成することは、テストの信頼性と完全性を保証するうえで重要な役割を果たす。Kotestではさまざまな方法でテストデータを生成できる。たとえば、テスト関数内で直接データを定義したり、テストケースクラス内のメンバー変数としてデータを定義したりできる。

package com.devkuma.kotest.tutorial.datadriven

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

class DataTest : StringSpec({

    val testData = listOf(1, 2, 3, 4, 5)

    "事前定義されたテストデータを使用してテスト" {
        testData.size shouldBe 5
    }

    "範囲内でテストデータを使用してテスト" {
        val generatedData = (1..10).toList()
        generatedData.size shouldBe 10
    }
})

上の例では、testDataというメンバー変数を使ってテストデータを定義している。また、generatedDataを使って範囲内のデータを生成している。

テストデータの設定と整理

テストが実行される前に必要なデータを設定し、テスト完了後にデータを整理することは、テストの安定性を保証するうえで重要である。KotestではbeforeTestafterTestブロックを使って、テストデータの設定と整理を行える。

package com.devkuma.kotest.tutorial.datadriven

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

class DataSetupAndCleanupTest : StringSpec({

    val database = setupDatabase()

    beforeTest {
        database.connect()
    }

    afterTest {
        database.disconnect()
    }

    "Test with database connection" {
        // データベース接続を使用したテストロジック
    }
})

fun setupDatabase(): Database {
    // Setup database and return
}

上の例では、beforeTestブロックでデータベースに接続し、afterTestブロックで接続を解除している。


テストデータの生成、管理、整理は、テストの信頼性を確保するための重要な要素である。Kotestを使ってテストデータを効果的に管理すれば、安定した堅牢なテストを実行でき、ソフトウェアの品質を向上させることができる。


参考