Magnolia Tech

いつもコードのことばかり考えている人のために。

『実践プロパティベーステスト』の例題をScalaで解いていく その3

『実践プロパティベーステスト』の例題をScalaで解いていくシリーズの第3回目です。

前回は、テストが失敗して、収縮した結果を確認する、という内容でした。

blog.magnolia.tech

今回は第3章に入ります。


初めてプロパティベーステストを書き始めたとき、サンプルコードだけを見て、「なるほどこう書けばいいのか!」と納得して書き始めたものの、すぐにピタっと手が止まりました。それは、「テスティングフレームワークが提供するAPIは分かった、でもそれを使ってどんなテストを書けば、プロパティベーステストになるのか?」...このとっかかりが分かりませんでした。

色々なテストコードの事例を見ながら、実際に自分で書いてみて少しずつ感覚をつかんでいきましたが、やはり何らかのとっかかりが有ると理解が早く進みます。


第3章「プロパティで考える」では、以下の4つの観点でプロパティベーステストを書くときの観点を紹介しています。

  1. モデル化

    テストしたいコードと同じ振る舞いをするコード(たいてい効率が悪い)と、結果を比較する手法です。 ビジネスロジックのような固有の振る舞いをコードに落とすような場合には、モデル化できるものは少ないと思いますが、汎用的なライブラリや、他の言語の実装から移植などでは有効です。

  2. 事例テストを汎化する

    これが一番分かりやすい取り組み方と言えます。通常のユニットテストを書いてみて、そのパターンを元に抽象化を行い、プロパティベーステストとしてまとめていく手法です。例えばリストの要素がゼロの場合、一つの場合、二つの場合...と増やしていったときに、その長さに依存しないような抽象化ができれば、プロパティベースのテストとして実装できます。

    ユニットテストベースのテストを考え、そこから抽象を見出す……という流れが遠回りにも感じられますが、具体的なテストから出発する分、結局これが一番早く、確実な手法と言えます。

  3. 不変条件

    一つのテストですべてをカバーするのではなく、複数のテストを組み合わせて、そのコードの振る舞いの確からしさを総合的に判断するための手法です。それぞれ分解した観点ごとに、不変条件(必ず満たさなければいけない条件)を特定し、それらがすべてテストをパスすれば、コード全体としての確からしさが確認できた、と言えるところからの発想です。

    何をもって不変条件が網羅したか?と考えるのは非常に難しいですが、一つの指標としてテストのカバレッジによる判断は有効であるといえるでしょう。

  4. 対称プロパティ

    JSONライブラリのような、エンコーダと、デコーダがセットで提供され、変換⇒再変換により元の結果に戻れば、その結果の正当性が確認できる、という手法です。

    適用できる場面は限られますが、適用できる場合は確実な方法です。

Scalaへ移植していく

本の中で紹介されていたbiggest関数をScalaに移植します。

package Pbt

object PbtHelper:
  def biggest[A](list: List[A])(using Numeric[A]): A =

    def go[A](l: List[A], m: A)(using Numeric[A]): A =
      val num = summon[Numeric[A]]

      (l, m) match
        case (Nil, i)    => i
        case (h :: t, i) if num.gteq(h, i) => go(t, h)
        case (_ :: t, i) => go(t, i)

    go(list.tail, list.head)

再帰とパターンマッチで書き直すと、割とさっぱりと書けます。 また、特定の型に依存したくなかったので、比較関数を提供するNumericを継承している型に限定して指定できるようにusing Numeric[A]の指定を入れています。ネストされた内部関数にまでusingは効果を及ぼさないので、内部関数の中でも指定しています。

summonはScala3から導入された関数で、usingの引数に型のみを指定した場合に、具体的なインスタンスを取得します。

この辺りの仕組みは、以下の公式ドキュメントに詳しく書かれています。

docs.scala-lang.org

次にテストを書いていきます。エンコードと、デコードは、ちょっと面倒だったので、circeで雑に済ませてしまいました。

import org.scalacheck.Properties
import org.scalacheck.Prop.forAll
import org.scalacheck.Arbitrary.*
import org.scalacheck.Gen.*

import Pbt.*

import io.circe.syntax.*
import io.circe.Json

object PbtTest extends Properties("Pbt Test"):

  property("最大の要素を見つける") =
    forAll(nonEmptyListOf(arbitrary[Int])): (x: List[Int]) =>
      PbtHelper.biggest(x) == modelBiggest(x)

  def modelBiggest(list: List[Int]): Int = list.sorted.last

  property("最後の数を選ぶ") =
    forAll(listOf(arbitrary[Int]), arbitrary[Int]): (list: List[Int], knownLast: Int) =>
      val knownList = list :+ knownLast
      knownLast == knownList.last

  property("ソート済みリストは整列したペアを持つ") =
    forAll(listOf(arbitrary[Int])): (list: List[Int]) =>
      isOrdered(list.sorted)

  def isOrdered(list: List[Int]): Boolean =
    list match
      case h1 :: h2 :: t => (h1 <= h2) && isOrdered(h2 :: t)
      case _ => true // 2要素未満のリスト

  property("ソート済みのリストはサイズを維持する") =
    forAll(listOf(arbitrary[Int])): (list: List[Int]) =>
      list.length == list.sorted.length

  property("何も要素が追加されなかった") =
    forAll(listOf(arbitrary[Int])): (list: List[Int]) =>
      val sorted = list.sorted
      sorted.forall(x => list.contains(x))

  property("何も要素が削除されなかった") =
    forAll(listOf(arbitrary[Int])): (list: List[Int]) =>
      val sorted = list.sorted
      list.forall(x => sorted.contains(x))
 
  property("対称的なエンコードとデコード") =
    forAll: (l: List[Map[String, Int]]) =>
      val encoded = enocde(l)
      l == decode(encoded)

  def enocde(o: List[Map[String, Int]]): Json = o.asJson
  def decode(j: Json): List[Map[String, Int]] = j.as[List[Map[String, Int]]].getOrElse(Nil)