cats lawsによる法則チェック
はじめに
去年から純粋関数型データ構造の演習をやっていてリストやヒープなどのデータ構造を実装しているのですがこれに対して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に対するテストが簡単に記述できました。