Kotest 縮小(Shrinking)

プロパティベーステストで最初に見つかった失敗ケースには、実際にテストを失敗させる原因ではない多くの複雑さが含まれていることがある。

縮小(Shrinking)

プロパティベーステストで最初に見つかった失敗ケースには、実際にテストを失敗させる原因ではない多くの複雑さが含まれていることがある。縮小とは、プロパティベーステストフレームワークが失敗ケースを単純化し、再現可能な最小ケースを見つける仕組みである。Kotest でジェネレーターが失敗ケースを縮小する方法は、shrinking interface の実装によって定義される。組み込みジェネレーターには通常、フレームワークによって定義されたデフォルト shrinker があり、カスタムジェネレーターにはカスタム shrinker 実装を指定できる。

組み込みジェネレーターの縮小

組み込みジェネレーター(ジェネレーター一覧を参照)には、フレームワークで定義されたデフォルトの縮小関数がある。縮小関数は、テストに失敗した値を入力として受け取り、Kotest がテストに適用できる新しい値のリストを返す。正確な動作はデータ型によって異なる。たとえば文字列の場合、最初または最後の文字を削除して縮小でき、整数の場合は値を小さくしたり半分にしたりできる。また、空文字列や整数 0 のようなエッジケースに対しても縮小動作が定義されている。これらのジェネレーターを使用するテストが失敗すると、縮小が実行される。

Arb.positiveInt().checkAll { i ->
    calculateProperty(i) shouldBe true
}

生成された入力の 1 つでテストが失敗すると、縮小結果が表示される。

Property test failed for inputs

0) 1792716902

Caused by io.kotest.assertions.AssertionFailedError: expected:<1792716902> but was:<0> at
    PropertyBasedTest$1$1$3$1.invokeSuspend(PropertyBasedTest.kt:54)
    PropertyBasedTest$1$1$3$1.invoke(PropertyBasedTest.kt)
    PropertyBasedTest$1$1$3$1.invoke(PropertyBasedTest.kt)
    io.kotest.property.internal.ProptestKt$proptest$3$2.invokeSuspend(proptest.kt:45)

Attempting to shrink arg 1792716902
Shrink #1: 1 pass
Shrink #2: 597572300 fail
Shrink #3: 199190766 fail
Shrink #4: 66396922 fail
Shrink #5: 22132307 fail
Shrink #6: 7377435 fail
Shrink #7: 2459145 fail

[...]

Shrink #999: 29948 pass
Shrink #1000: 44922 pass
Shrink #1001: 59896 pass
Shrink #1002: 89839 fail
Shrink result (after 1002 shrinks) => 89839

Caused by io.kotest.assertions.AssertionFailedError: expected:<89839> but was:<0> at
    PropertyBasedTest$1$1$3$1.invokeSuspend(PropertyBasedTest.kt:54)
    PropertyBasedTest$1$1$3$1.invoke(PropertyBasedTest.kt)
    PropertyBasedTest$1$1$3$1.invoke(PropertyBasedTest.kt)
    io.kotest.property.internal.ShrinkfnsKt$shrinkfn$1$1$smallestA$1.invokeSuspend(shrinkfns.kt:19)

デフォルトでは、Kotest は 1000 回縮小する。この動作は構成できる。たとえば、制限なく縮小を続けたい場合は次のようにする。

Arb.positiveInt().checkAll(PropTestConfig(shrinkingMode = ShrinkingMode.Unbounded)) { i ->
    calculateProperty(i) shouldBe true
}

カスタムジェネレーターの縮小

カスタムジェネレーターには、Kotest で定義された shrinker がない。代わりに、カスタム shrinker を実装できる。以下は、shrinker が値自体の隣にある座標を返す例である。

data class Coordinate(val x: Int, val y: Int)

class CoordinateTest : FunSpec({
    context("Coordinate Transformations") {
        // Shrinker takes the four neighbouring coordinates
        val coordinateShrinker = Shrinker<Coordinate> { c ->
            listOf(
                Coordinate(c.x - 1, c.y),
                Coordinate(c.x, c.y - 1),
                Coordinate(c.x + 1, c.y),
                Coordinate(c.x, c.y + 1),
            )
        }
        val coordinateArb = arbitrary(coordinateShrinker) {
            Coordinate(Arb.nonNegativeInt().bind(), Arb.nonNegativeInt().bind())
        }

        test("Coordinates are always positive after transformation") {
            coordinateArb.checkAll {
                transform(it).x shouldBeGreaterThanOrEqualTo 0
                transform(it).y shouldBeGreaterThanOrEqualTo 0
            }
        }
    }
})

参照