Search code examples
scalascalacheckscala-cats

How to test Monad instance using discipline


Given a monad for Fun type

type FUN[A] = Map[String, String] => (List[String], A)

val funMonad: Monad[FUN] = new Monad[FUN] {
  override def flatMap[A, B](fa: FUN[A])(f: (A) => FUN[B]): FUN[B] = m => {
    val (list1, a1) = fa(m)
    val (list2, a2) = f(a1)(m)
    (list1 ++ list2, a2)
  }
  override def pure[A](x: A): FUN[A] = m => (Nil, x)
}

The question is: How man can use discipline library to test that this instance of Monad obeys Monad Laws?

Below is partial result, which fails because compiler could not find implicit for CartesianTests.Isomorphisms[FUN].

import cats.Monad
import cats.kernel.Eq
import org.scalacheck.rng.Seed
import org.scalacheck.{Arbitrary, Gen}

class MyMonadSpec extends FunSuite with scalatest.Discipline {

...

implicit def funEq[T: Eq]: Eq[FUN[T]] = {
  val sampleInput: Map[String, String] = {
    def genMap: Gen[Map[String, String]] = for {
      size <- Gen.size
      keys <- Gen.containerOfN[List, String](size, Arbitrary.arbitrary[String])
      values <- Gen.containerOfN[List, String](size, Arbitrary.arbitrary[String])
    } yield keys.zip(values).toMap

    genMap(Gen.Parameters.default.withSize(10), Seed.apply(123L)).get
  }

  Eq.instance[FUN[T]] ((f1, f2) => f1(sampleInput) == f2(sampleInput))
}

import cats.kernel.instances.int._
import cats.kernel.instances.tuple._
import cats.laws.discipline.MonadTests


checkAll("Int", MonadTests[FUN](funMonad).monad[Int, Int, Int])

//Error: could not find implicit value for parameter iso: cats.laws.discipline.CartesianTests.Isomorphisms[[A]scala.collection.immutable.Map[String,String] => (List[String], A)]

Solution

  • Normally you could just put your instance into scope and do this (you could clean this up a bit by importing cats.instances.all._, but I'm being explicit for clarity):

    import cats.instances.int._
    import cats.instances.list._
    import cats.instances.map._
    import cats.instances.string._
    import cats.instances.tuple._
    import cats.laws.discipline.MonadTests
    import cats.laws.discipline.eq._
    
    MonadTests[FUN].monad[Int, Int, Int].all.check
    

    You'd get the Isomorphisms instance for free, since it only requires an Invariant instance, which is implied by the Monad. Also note that you shouldn't need to define your own Eq instances—the eq package provides a Function1 instance that's appropriate for testing for you.

    In this case the compiler won't actually find the Invariant instance, though (this could be an SI-2712 issue or it could have something to do with the alias—off the top of my head I'm not sure), and it seems like you want to test the monad instance without putting it into implicit scope. One easy way to do this is to provide your own Isomorphisms:

    import cats.laws.discipline.CartesianTests.Isomorphisms
    
    implicit val funIsomorphisms: Isomorphisms[FUN] = Isomorphisms.invariant(funMonad)
    

    Or as a complete working example (on Cats 0.7.2):

    import cats.Monad
    import cats.instances.int._
    import cats.instances.list._
    import cats.instances.map._
    import cats.instances.string._
    import cats.instances.tuple._
    import cats.instances.map._
    import cats.laws.discipline.CartesianTests.Isomorphisms
    import cats.laws.discipline.MonadTests
    import cats.laws.discipline.eq._
    
    type FUN[A] = Map[String, String] => (List[String], A)
    
    val funMonad: Monad[FUN] = new Monad[FUN] {
      def flatMap[A, B](fa: FUN[A])(f: (A) => FUN[B]): FUN[B] = m => {
        val (list1, a1) = fa(m)
        val (list2, a2) = f(a1)(m)
        (list1 ++ list2, a2)
      }
      def pure[A](x: A): FUN[A] = m => (Nil, x)
      def tailRecM[A, B](a: A)(f: A => FUN[Either[A, B]]): FUN[B] = defaultTailRecM(a)(f)
    }
    
    implicit val funIsomorphisms: Isomorphisms[FUN] = Isomorphisms.invariant(funMonad)
    

    And then:

    scala> MonadTests[FUN](funMonad).monad[Int, Int, Int].all.check
    + monad.ap consistent with product + map: OK, passed 100 tests.
    + monad.applicative homomorphism: OK, passed 100 tests.
    + monad.applicative identity: OK, passed 100 tests.
    + monad.applicative interchange: OK, passed 100 tests.
    + monad.applicative map: OK, passed 100 tests.
    + monad.apply composition: OK, passed 100 tests.
    + monad.cartesian associativity: OK, passed 100 tests.
    + monad.covariant composition: OK, passed 100 tests.
    + monad.covariant identity: OK, passed 100 tests.
    + monad.flatMap associativity: OK, passed 100 tests.
    + monad.flatMap consistent apply: OK, passed 100 tests.
    + monad.followedBy consistent flatMap: OK, passed 100 tests.
    + monad.invariant composition: OK, passed 100 tests.
    + monad.invariant identity: OK, passed 100 tests.
    + monad.map flatMap coherence: OK, passed 100 tests.
    + monad.monad left identity: OK, passed 100 tests.
    + monad.monad right identity: OK, passed 100 tests.
    + monad.monoidal left identity: OK, passed 100 tests.
    + monad.monoidal right identity: OK, passed 100 tests.
    + monad.mproduct consistent flatMap: OK, passed 100 tests.
    + monad.tailRecM consistent flatMap: OK, passed 100 tests.
    

    (You could also use checkAll—I'm just doing .all.check because it doesn't require you to have ScalaTest around or instantiate a FunSuite.)