/var/log/jsoizo

メモ帳 技術とか趣味とか

KotestでTable Driven Testする

KotestでTable Driven Testをサポートしているシンタックスがあるのでそれを使えば良い。 JUnitでもやっている例はあるけど、Kotestの方がデータ型の用意が不要だったりするのと、テストの可読性が高そうにみえる(好みの範疇)。

また、Table Drivenにテストを書くことで、テストケースの網羅性が確認しやすいのと、テスト実行のためのボイラープレートを減らせるので、入力パターンが3件以上くらいになってくると良いと思われる。

なおテストの実行上はテーブル内にテストケース名を書かなくても良いが、テスト仕様書として考えると必要であり、個人的なおすすめとしては必ずテーブルの1列目にテストケース名を入れるようにした上で、forAllのブロック内でテストケースの引数だけを _ として無視してあげることである。

fun isMultipleOfThreeOrContainsThree(int: Int): Boolean {
    return int % 3 == 0 || int.toString().contains("3")
}

class TableBasedTestExample : FunSpec({
    test("isMultipleOfThreeOrContainsThree") {
        val table = table(
            headers("case", "given", "expected"),
            row("3の倍数", 12, true),
            row("3を含む", 24357, true),
            row("3の倍数でもなく3も含まない", 44, false)
        )
        forAll(table) { _, given, expected ->
            val result = isMultipleOfThreeOrContainsThree(given)
            assert(result == expected)
        }
    }
})

他の例として、よくあるコンストラクタの中でrequireするような処理の場合、
例外処理になってassertが面倒になるので以下のように一度 Resut<T> でラップしてあげてからassertすると良い。
kotestにResut用のassertion関数が提供されているのでそれでいい感じにできるはず。

data class UserName(val value: String) {
    init {
        val NG_WORDS = listOf(
            "ng_word",
            "ng_word_2"
        )
        require(value.isNotEmpty()) { "UserName is empty" }
        require(!NG_WORDS.any { value.contains(it) }) { "UserName having ng words, value: $value" }
    }
}

class TableBasedTestExample : FunSpec({
    test("UserNameの生成") {
        val table = table<String, String, (Result<UserName>) -> Unit>(
            headers("case", "given", "assertion"),
            row("OKな文字列",
                "Valid Name",
                { it.shouldBeSuccess { it.value.shouldBe("Valid Name") } }),
            row("空文字",
                "",
                { it.shouldBeFailure { it.message.shouldBe("UserName is empty") } }),
            row(
                "NGワード",
                "A ng_word bar",
                { it.shouldBeFailure { it.message.shouldBe("UserName having ng words, value: A ng_word bar") } })
        )
        forAll(table) { _, given, assertionFunc ->
            val result = kotlin.runCatching {
                UserName(given)
            }
            assertionFunc(result)
        }
    }
})

この例をみる限り、大抵の事前条件違反はユニットテストを書いていくことを考えると、インスタンス生成を行う際にバリデーション結果を例外で投げるよりもResultやEither<A,B>のようなデータ型を返したほうがテストの書きやすさが上がるということがなんとなく感じられるはずである。

別解として、 kotest-framework-datatest の記法を使うこともできる。
正直tableを用いいるのと差はないと思うが、ヘッダが用意できる/できないの違いでtableのほうが好きである。

    test("isMultipleOfThreeOrContainsThree withData版") {
        withData(
            Triple("3の倍数", 12, true),
            Triple("3を含む", 24357, true),
            Triple("3の倍数でもなく3も含まない", 44, false)
        ) { (_, given, expected) ->
            val result = isMultipleOfThreeOrContainsThree(given)
            assert(result == expected)
        }
    }