Keep on moving

あんまりまとまってないことを書きますよ

KotestでProperty-Based-TestingするときのTips

こんにちは、Kotlin初心者です。

TL;DR

この記事は前日のこちらの記事の続きです。が、Property-Based-Testing知ってる方はそのまま読んでもらって問題ありません。

masahito.hatenablog.com

いろいろ書いてみた結果をまとめた

  • forAll() と checkAll()の使い分け
  • generationの回数を増やしたいor減らしたい
  • 失敗したときにもう少しテストを流したい
  • CustomGenerator

ひとことでいうと

github.com このドキュメント読むといいよ

forAll() と checkAll()の使い分け

ひとことで行ってしまうと関数の定義が違う

  • forAll : (a, ..., n) -> Boolean
  • check All : (a, ..., n) -> Unit

以上。例はこんな感じ

class PropertyExample: StringSpec({
    "String size" {
        forAll<String, String> { a, b ->
            (a + b).length == a.length + b.length
        }
    }

    "String size2" {
        checkAll<String, String> { a, b ->
            (a + b).length shouldBe  a.length + b.length
        }
    }
})

The checkAll approach will consider a test valid if no exceptions were thrown. なので、ちょっと考え方が違うとも言える。 自分はBooleanを返してほしい派なので大体forAllをつかっている。

generationの回数を増やしたいor減らしたい

標準では1000回生成するそう。増やしたいときには以下のようにする。

class PropertyExample: StringSpec({
   "some test" {
      checkAll<Double, Double>(iterations = 10000) { a, b ->
         // test here
      }
   }
})

失敗したときにもう少しテストを流したい

デフォルトでは失敗したら即打ち切りなんだけど、もっとテスト失敗パターンを出すことも可能。以下のようにする.

class PropertyExample: StringSpec({
   "some flakey test" {
      forAll<String, String>(PropTestConfig(maxFailure = 3)) { a,b ->
         // max of 3 inputs can fail
      }
   }
})

CustomGenerator

Generated values are provided by instances of the sealed class Gen. You can think of a Gen as kind of like an input stream but for property testing. Each Gen will provide a (usually) infinite stream of these values. Kotest has two types of generators - Arb for arbitrary (random) values and Exhaustive for a finite set of values in a closed space.

KotestではGenが基底クラスとして、2種類のジェネレータークラスが提供されている。

To write your own generator for a type T, you just create an instance of Arb or Exhaustive.

というわけで Arb<T> or Exhaustive<T>インスタンスを実装することで自分好みのGeneratorをつくれる

data class Yen(val amount: Long)
test("金額は5000円より高い") {
        val yenArb = arb { rs ->
            val nums = Arb.long(LongRange(5001, 10000)).values(rs)
            nums.map { num -> Yen(num.value) }
        }
        forAll<Yen>(yenArb) { yen1 ->
            yen1.amount > 5000L
        }
}

2変数以上のクラスをGenerateするためのTips

2変数くらいなら zip でOK.

        val userArb = arb { rs ->
            val names = Arb.string(minSize = 1).values(rs)
            val ages = Arb.int().values(rs)
            names.zip(ages).map { (name, age) ->
                User(
                    name.value,
                    age.value
                )
            }
        }

3変数以上なら Arb.bind() がおすすめ

        val orderArb: Arb<Order> =
            Arb.bind(
                Arb.long(0L),
                Arb.string(1, 24)
            ) { a, b -> Order(Item(a, b)) }

別解としてはflatmap+mapでもできたりします(for expression♫)

        val expiredCardArb: Arb<CreditCard> = arb { rs ->
            val nums = Arb.int(0, 9999).values(rs)
            val holders = Arb.string(minSize = 1).values(rs)
            val diffs = Arb.long(1, 24).values(rs)
            nums.flatMap { num ->
                diffs.flatMap { diff ->
                    holders.map { holder ->
                        val numStr = num.value.toString().padStart(4, '0')
                        CreditCard(
                            number = "${numStr}-${numStr}-${numStr}-${numStr}",
                            limit = today.minusMonths(diff.value),
                            holder = holder.value
                        )
                    }
                }
            }
        }

とはいえせっかくなので bindを使ったほうがよさそう。Sequenceで結果返してるのも実行効率考えると良さそう。

// 実装はこんな感じ
fun <A, B, T : Any> Arb.Companion.bind(genA: Gen<A>, genB: Gen<B>, bindFn: (A, B) -> T): Arb<T> = arb {
   val iterA = genA.generate(it).iterator()
   val iterB = genB.generate(it).iterator()
   generateSequence {
      val a = iterA.next()
      val b = iterB.next()
      bindFn(a.value, b.value)
   }
}

kotest/generators.md at master · kotest/kotest · GitHub

まとめ

いろいろ書いてみた結果をまとめた

  • forAll() と checkAll()の使い分け
  • generationの回数を増やしたいor減らしたい
  • 失敗したときにもう少しテストを流したい
  • CustomGenerator

ドキュメント読んでてscalaCheckなど先に出ているライブラリをちゃんと意識している印象です。 propertyをちゃんと見出すことができれば結構簡単に値をCustomeGenerator使えるのがいい感じです。 ぜひProperty-Based-Testing使ってみると良いと思います。 コードで変なところあったらぜひツッコミをください。

サンプルコード

github.com