cats lawsによる法則チェック

cats lawsによるlaw testingを試してみました.
2022.01.20

はじめに

去年から純粋関数型データ構造の演習をやっていてリストやヒープなどのデータ構造を実装しているのですがこれに対してlaw testingをしてみたくなったのでcats に含まれる型クラスに対するlaw testingが定義されているcats lawsを試してみました。

disciplineによるlaw testing

cats lawsはcatsに含まれる型クラスのテストをdisciplineで定義したものなのでまずはdisciplineをみていきます。 disciplineではlaw testingをScalacheckにおけるPropで記述しますが、法則同士の関係をあらわすためにLawsおよびRuleSetとして抽象を導入しています。

RuleSet

ある型クラスの要求する法則はLawsで記述され、LawsはRuleSetから構成されています。RuleSetはそのRuleSetが要求する複数のPropと、その型クラスが他のクラスから受け継いだRuleSetを含みます。ここでparentsには型クラスの階層構造(e.g. Functor → MonadあるいはSemiGroup→Monoid)における親クラスを、basesは階層構造内以外のクラスを指定します。

trait Laws {

  trait RuleSet {
    def name: String
    def bases: Seq[(String, Laws#RuleSet)] = Seq()
    def parents: Seq[RuleSet] = Seq()
    def props: Seq[(String, Prop)] = Seq()

    // ...
  }

}

Monoidの例

Cats Lawsに定義されているMonoidのRuleSetは以下のようになっています(コードはCats Lawsから抜粋) Semigroupでは結合則、Monoidではそれに加えて単位元を確認するPropがそれぞれ定義されているのがわかります。

class DefaultRuleSet(
      val name: String,
      val parent: Option[RuleSet],
      val props: (String, Prop)*
  ) extends RuleSet
      with HasOneParent {
    val bases = Seq.empty
  }

//monoidのルール定義
def monoid(implicit arbA: Arbitrary[A], eqA: Eq[A]): RuleSet =
    new DefaultRuleSet(
      name = "monoid",
      parents = Some(semigroup),
      rules = "left identity" -> forAll(laws.leftIdentity _),
      "right identity" -> forAll(laws.rightIdentity _),
      "combine all" -> forAll(laws.combineAll _),
      "collect0" -> forAll(laws.collect0 _),
      "is id" -> forAll((a: A) => laws.isId(a, eqA)),
      "repeat0" -> forAll(laws.repeat0 _)
    )
//semigroupは以下のようになっている
def semigroup(implicit arbA: Arbitrary[A], eqA: Eq[A]): RuleSet =
    new DefaultRuleSet(
      "semigroup",
      None,
      "associative" -> forAll(laws.semigroupAssociative _),
      "repeat1" -> forAll(laws.repeat1 _),
      "repeat2" -> forAll(laws.repeat2 _),
      "combineAllOption" -> forAll(laws.combineAllOption _),
      "reverseReverses" -> forAll(laws.reverseReverses _),
      "reverseRepeat1" -> forAll(laws.reverseRepeat1 _),
      "reverseRepeat2" -> forAll(laws.reverseRepeat2 _),
      "reverseCombineAllOption" -> forAll(laws.reverseCombineAllOption _),
      "intercalateIntercalates" -> forAll(laws.intercalateIntercalates _),
      "intercalateRepeat1" -> forAll(laws.intercalateRepeat1 _),
      "intercalateRepeat2" -> forAll(laws.intercalateRepeat2 _),
      "intercalateCombineAllOption" -> forAll(laws.intercalateCombineAllOption _)
    )

disciplineについては「Law Enforcement using Discipline」が詳しいです。

law testingをやってみる

disciplineについてもなんとなくわかったのでcatsのドキュメント「Law Testing」の内容を参考に実際テストをやってみます。

sbtファイル

今回使うsbtファイルは以下の通りです。

ThisBuild / version := "0.1.0-SNAPSHOT"

ThisBuild / scalaVersion := "2.13.8"

lazy val root = (project in file("."))
  .settings(
    name := "law-testing",
    libraryDependencies ++= Seq(
      "org.typelevel" %% "cats-core" % "2.7.0",
      "org.typelevel" %% "cats-laws" % "2.7.0" % Test,
      "org.typelevel" %% "discipline-scalatest" % "2.1.5" % Test,
      "com.github.alexarchambault" %% "scalacheck-shapeless_1.15" % "1.3.0" % Test
    ),
    scalacOptions := Seq("-unchecked", "-deprecation")
  )

テスト対象のデータ構造

テスト対象のヒープは下記です(簡単のため記事と関係ないコードは省略)。

package example
import cats.{Eq, Functor}

sealed trait Heap[A]

final case class E[A]() extends Heap[A]
final case class T[A](a: A, left: Heap[A], right: Heap[A]) extends Heap[A]

object Heap {
  //同値チェックのためEqが必要
  implicit def eqTree[A: Eq]: Eq[Heap[A]] = Eq.fromUniversalEquals

  implicit def functor: Functor[Heap] = new Functor[Heap] {
    override def map[A, B](fa: Heap[A])(f: A => B): Heap[B] = fa match {
      case _: E[A]    => E[B]()
      case T(a, l, r) => T(f(a), map(l)(f), map(r)(f))
    }
  }
}

テストコード

とても短いですがHeapがFunctor則を満たしているかのテストは下記になります。

実質的にはFunctorTests[Heap].functor[Int, Int, String] でプリセットのRuleSet(とそれに含まれているProp)を生成しています。型パラメータA, B, CはF(g・f) = F(g)・F(f) (f: A⇒B, g: B⇒ Cな関数)のテストに必要なので指定しています。必要な型パラメータは型クラスによって異なります(先述のMonoidでは1つ)。

package example

import cats.laws.discipline.FunctorTests
import org.scalatest.funsuite.AnyFunSuiteLike
import org.typelevel.discipline.scalatest.FunSuiteDiscipline
//ScalacheckのArbitary[Heap[A]]が必要なのでScalacheckShapelessで生成する
import org.scalacheck.ScalacheckShapeless._ 

class HeapLawTests
    extends AnyFunSuiteLike
    with org.scalatest.prop.Configuration
    with FunSuiteDiscipline {
  checkAll(
    "Heap[Int].FunctorLaws",
    FunctorTests[Heap].functor[Int, Int, String]
  )
}]

上記を実行すると......

sbt:law-testing> test
[info] HeapLawTests:
[info] - Heap[Int].FunctorLaws.functor.covariant composition
[info] - Heap[Int].FunctorLaws.functor.covariant identity
[info] - Heap[Int].FunctorLaws.functor.invariant composition
[info] - Heap[Int].FunctorLaws.functor.invariant identity
[info] Run completed in 911 milliseconds.
[info] Total number of tests run: 4
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 4, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.

Functorを正しく実装できていたようです。

まとめ

cats laws およびdisciplineで独自に実装したFunctorに対するテストが簡単に記述できました。