Law testing
Laws are an important part of cats. Cats uses discipline to define type class laws and the ScalaCheck tests based on them.
To test type class laws from Cats against your instances, you need to add a cats-laws dependency.
Getting started
First up, you will need to specify dependencies on cats-laws in your build.sbt file.
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-laws" % "2.13.0" % Test,
)
Example: Testing a Functor instance
We'll begin by creating a data type and its Functor instance.
import cats._
sealed trait Tree[+A]
case object Leaf extends Tree[Nothing]
case class Node[A](p: A, left: Tree[A], right: Tree[A]) extends Tree[A]
object Tree {
implicit val functorTree: Functor[Tree] = new Functor[Tree] {
def map[A, B](tree: Tree[A])(f: A => B) = tree match {
case Leaf => Leaf
case Node(p, left, right) => Node(f(p), map(left)(f), map(right)(f))
}
}
}
Cats defines all type class laws tests in cats.laws.discipline.*
as discipline's RuleSets. Each RuleSet provides a ScalaCheck Properties through
ruleSet.all to represent all the rules that it defines and inherits. For example,
the ScalaCheck Properties for Functor can be retrieved using
cats.laws.discipline.FunctorTests[Tree].functor[Int, Int, String].all
We will also need to create an Eq instance, as most laws will need to compare values of a type to properly test for correctness.
For simplicity we'll just use Eq.fromUniversalEquals:
implicit def eqTree[A: Eq]: Eq[Tree[A]] = Eq.fromUniversalEquals
ScalaCheck requires Arbitrary instances for data types being tested. We have defined an Arbitrary instance for Tree here,
if you use Scala 2, you can avoid writing it manually with scalacheck-shapeless.
import org.scalacheck.{Arbitrary, Gen}
object arbitraries {
implicit def arbTree[A: Arbitrary]: Arbitrary[Tree[A]] =
Arbitrary(Gen.oneOf(Gen.const(Leaf), (for {
e <- Arbitrary.arbitrary[A]
} yield Node(e, Leaf, Leaf)))
)
}
Now we can convert these ScalaCheck Properties into tests that the test framework can run. discipline provides a helper checkAll function that performs
this conversion for three test frameworks: ScalaTest, Specs2 and MUnit.
-
If you are using
Specs2, extend your test class withorg.typelevel.discipline.specs2.Discipline(provided bydiscipline-specs2). -
If you are using
ScalaTest, extend your test class withorg.typelevel.discipline.scalatest.FunSuiteDiscipline(provided bydiscipline-scalatest) andorg.scalatest.funsuite.AnyFunSuiteLike. -
If you are using
MUnit, extend your test class withmunit.DisciplineSuite(provided bydiscipline-munit). -
For other test frameworks, you need to resort to their integration with
ScalaCheckto test theScalaCheckPropertiesprovided bycats-laws.
The following example is for MUnit.
import cats.syntax.all._
import cats.laws.discipline.FunctorTests
import munit.DisciplineSuite
import arbitraries._
class TreeLawTests extends DisciplineSuite {
checkAll("Tree.FunctorLaws", FunctorTests[Tree].functor[Int, Int, String])
}
cats.implicits._imports the instances we need forEq[Tree[Int]], which the laws use to compare trees.FunctorTestscontains the functor laws.AnyFunSuitedefines the style of ScalaTest.FunSuiteDisciplineprovidescheckAll, and must be mixed intoAnyFunSuitearbitraries._imports theArbitrary[Tree[_]]instances needed to check the laws.
Now when we run test in our sbt console, ScalaCheck will test if the Functor laws hold for our Tree type.
You should see something like this:
[info] TreeLawTests:
[info] - Tree.FunctorLaws.functor.covariant composition (58 milliseconds)
[info] - Tree.FunctorLaws.functor.covariant identity (3 milliseconds)
[info] - Tree.FunctorLaws.functor.invariant composition (19 milliseconds)
[info] - Tree.FunctorLaws.functor.invariant identity (3 milliseconds)
[info] Passed: Total 4, Failed 0, Errors 0, Passed 4
[success] Total time: 2 s, completed Aug 2, 2019 12:01:17 AM
And voila, you've successfully proven that your data type upholds the Functor laws!
Testing cats.kernel instances
For most of the type classes included in Cats, the above will work great.
However, the law tests for the type classes inside cats.kernel are located in cats.kernel.laws.discipline.* instead.
So we have to import from there to test type classes like Semigroup, Monoid, Group or Semilattice.
Let's test it out by defining a Semigroup instance for our Tree type.
import cats.syntax.all._
implicit def semigroupTree[A: Semigroup]: Semigroup[Tree[A]] = new Semigroup[Tree[A]] {
def combine(x: Tree[A], y: Tree[A]) = (x, y) match {
case (Leaf, _) => Leaf
case (_, Leaf) => Leaf
case (Node(xp, xLeft, xRight), Node(yp, yLeft, yRight)) =>
Node(xp |+| yp, combine(xLeft, yLeft), combine(xRight, yRight))
}
}
Then we can add the Semigroup tests to our suite:
import cats.syntax.all._
import cats.kernel.laws.discipline.SemigroupTests
import cats.laws.discipline.FunctorTests
import munit.DisciplineSuite
import arbitraries._
class TreeLawTests extends DisciplineSuite {
checkAll("Tree.FunctorLaws", FunctorTests[Tree].functor[Int, Int, String])
checkAll("Tree[Int].SemigroupLaws", SemigroupTests[Tree[Int]].semigroup)
}