/var/log/jsoizo

メモ帳 技術とか趣味とか

個人的に好きなKotestのmatcherたち 〜JUnitやkotlin.testでも使えるよ〜

この記事は検索エンジンプロダクトを一緒に開発してた同窓会 Advent Calendar 2023 の3日目。の代打です。

第二子出産に立ち会うまでのもどかしい待ち時間で心を落ち着けるために書いている。日本語がぐっちゃぐちゃかもしれない。

さて、現在の仕事ではKotlinプログラムのユニットテストを書くときにKotestを利用している。
KotestはScalaTestからインスパイアされたもので、Specの書き方が豊富だったりProperty Based Testingのサポートなどの特徴があるが、他にScalaTestから引き継いだ良い点としてmatcherの豊富さがある。

その中でも個人的に好きなものをいくつか列挙しつつ、利用するシチュエーションを説明していく。なお、"JUnitやkotlin.testでも利用可能である" ということを示すためにサンプルはすべてkotlin.testで記載する。

使い方

詳細は公式ドキュメントを参照

kotest.io

以下はbuild.gradle.ktsのdependency部分だけ抽出したもの。

dependencies {
    val kotestVersion = "5.8.0"
    testImplementation("io.kotest:kotest-assertions-core:$kotestVersion")
    testImplementation("io.kotest:kotest-assertions-json:$kotestVersion")

    implementation("io.arrow-kt:arrow-core:1.2.0")
    testImplementation("io.kotest.extensions:kotest-assertions-arrow:1.4.0")
}

testing {
    suites {
        val test by getting(JvmTestSuite::class) {
            useKotlinTest("1.9.20")
        }
    }
}

コレクション編

コレクションのテストって、とりあえず list shouldBe expected みたいに値の一致で検査しがちなんだけど、関数の仕様によっては順序を持たない集合や条件付きの集合を返すためにコレクションを利用することがあるはずで。そういう際にはこれらのmatcherを利用していくことでテストからそういった仕様を見通すことが容易になり、リファクタリングがしやすくなったり。

shouldContainExactlyInAnyOrder: コレクションの要素が引数に与えた値と完全に一致していることを検査するが、順序は問わない

これの好きなところは、以下の例にもある通りに同一の値が2回出現したとして、expectedにも同様に同じ値が2回出現していないとテストがしっかりコケてくれる点。assertする実装を都度書くと結構たいへんだと思うこれ。

@Test
fun shouldContainExactlyInAnyOrder() {
    val list = listOf(1, 2, 3, 4, 4)
    val expected = listOf(3, 4, 2, 1, 4)
    list.shouldContainExactlyInAnyOrder(expected)
}

shouldHaveLowerBound : Collection<Comparable<T>> が特定の値よりも大きいことを検査する。値オブジェクトはよくCompalableを実装していることが多いので、それらを含む配列のフィルタ条件に関する仕様を表現しやすかったり。 shouldHaveUpperBound とセットで使えば範囲がわかる。

@JvmInline
value class Foo(private val v: String): Comparable<Foo> {
    override fun compareTo(other: Foo): Int = this.v.compareTo(other.v)
}

@Test
fun shouldHaveLowerBound() {
    val list = listOf(Foo("b"), Foo("d"), Foo("c"))
    list.shouldHaveLowerBound(Foo("b"))
}

JSON

仕事だとGaugeでATDD的なことをすることが多くて、その際にgauge-javaを利用してKotlinでstepを実装することになっている。
レスポスボディがJSONなWeb APIの受け入れテストを書くとき、返却するHTTP Bodyと同じ内容のJSONファイルを用意してassertしたい。

KotestのJSON matcherの強力なところで、assert関数の設定次第でJSONのフィールドの順序や配列内の要素の順序などに関して寛容に検査することができる。寛容にと言うのは、含んではいるが順序が異なる場合を許可する というもので、先述の配列の順序は問わないが集合として期待通りであるかを検査したいときなどに有用。

kotest.io

たとえばこのようなJSONを返却するものとして、、、

val json = """
    {
        "name": "Alice",
        "skills": ["Kotlin", "Java", "Scala"],
        "languages": ["Japanese", "English"],
        "address": {
            "country": "Japan",
            "city": "Tokyo"
        }
    }
    """.trimIndent()

デフォルトでは PropertyOrder.Lenient であるため、フィールドの順序は問わず存在していればよい。

@Test
fun default() {
    val expectedJson = """
    {
        "skills": ["Kotlin", "Java", "Scala"],
        "languages": ["Japanese", "English"],
        "address": {
            "country": "Japan",
            "city": "Tokyo"
        },
        "name": "Alice"
    }
    """.trimIndent()
    json.shouldEqualJson { expectedJson }
}

配列内の順序まで寛容で良い場合は ArrayOrder.Lenient 設定を入れて検査する。

@Test
fun arrayOrderLenient() {
    val expectedJson = """
    {
        "name": "Alice",
        "skills": ["Scala", "Kotlin", "Java"],
        "languages": ["Japanese", "English"],
        "address": {
            "country": "Japan",
            "city": "Tokyo"
        }
    }
    """.trimIndent()
    json.shouldEqualJson {
        this.arrayOrder = ArrayOrder.Lenient
        expectedJson
    }
}

ただし、すべての配列に対して順序を寛容に判定することになるため、順序が厳格な配列が同一のJSONオブジェクト内に含まれていた場合にその点が期待通りにassertすることができない。この設定を入れた上で、JSONから厳格にチェックしたい部分だけ取り出してそこだけ配列のチェックする?などが必要になりテストの可読性が落ちそうである。よいプラクティスはないだろうか。。。

shoudContainJsonKeyがUnitではなく対象のJSON値を返却してくれると、こういう感じにできて良さそうなんだけど。

val languagesJson: String = json.shouldContainJsonKey("languages")
val expected = val expected = """["Japanese", "English"]"""
languagesJson.shouldEqualJson{ expected }

Arrow編

あまり国内だと事例の少ない arrow-kt だけど、Either型の定義と便利関数が多く提供されており、それらを組み合わせることでエラーハンドリングまわりの複雑度下げられるのでよく使っている。具体どう使うと良いかは↓の翻訳記事を参照。

jsoizo.hatenablog.com

Either<A,B>.shouldBeRight : Either型の値がRightであることを検査する。 shouldBeLeft ならLeftであることを検査する。引数に elem: B を与える場合はEitherにラップされた値がelem: Bと合致するかどうかも検査できる。

@Test
fun shouldBeRight() {
    val e: Either<Throwable, String> = Either.Right("example")
    e.shouldBeRight("example")
}

さらに、 shouldBeRight なり shouldBeLeft はassertできた場合にEither<A, B>のBやAを返すので、このように値をEither型の値をテストコード中でアンラップするのにも利用できる。 .getOrNull!! みたいなコードでunsafeに値を取り出すことがなくなって良い。

@Test
fun shouldBeLeft() {
    val e: Either<Throwable, String> = Either.Left(Exception("error!!!"))
    val error = e.shouldBeLeft()
    assert(error.message == "error!!!")
}

さいごに

ざっとこんな感じで、テストコードの可読性を上げたりテストによって仕様を表現するためのテクニックとしてのmatcherをいくつか紹介した。他にも見つけたら使っていきたい所存。

余談なんだけど、このアドカレのための前職のLINEグループに現職の先輩がいることに驚いてびっくりしている。ちょうどつい先日退職されてしまったので、もう少し話すことができたら良かったと悔いている。。。 他の方の記事読んでてもためになることが多いし、きっかけを作っていただいたOrganizerの @mado-m さんに感謝。