Cats Law Based Testing in Scala 3

Scala 3
testing
cats-laws
discipline-scalatest
scalacheck
scalatest
Walkthrough of creating Scala 3 tests using cat-laws
Published

June 1, 2023

Cats Law Based Testing in Scala 3

In this post I outline dependencies needed in order to wrtie law based tests in Scala 3, provide copy paste snippets to use in your own tests and an sbt starter project you can clone to use in your next project.

Motivation

Let me start by saying the ideas expressed in what follows are nothing new. There are plenty blog posts, source code, and examples where folks perviously documented what I’m about to discuss. The difference in what I will present is how property based testing is now done with Scala 3 and give a full working starter project.

Background

When building applications you may require objects satisfy certain properties. For example:

  • Ref1 talks about requiring a User case class satisify the total order property.
  • Ref2 mentions crating a Tree data type with a Functor instance and shows how to verify it satisfies the functor laws.
  • Ref3 describes two examples, Semigroup and Moniods and shows how to verify the laws for each of those.
  • Ref3 shows a custom Monad and then demonstrates how to verify it satisfies the monad laws.

I will take the first example, from Ref1, and outline the changes needed to get it compiling in Scala 3. I urge you to vist each link and read through the examples and use what I outline here to make those examples work in the project I provide.

Required libraries

In order to get things working in your project you will need the following libraries:

// FunSuiteDiscipline
"org.typelevel" %% "discipline-scalatest" % "2.2.0" % Test,
// AnyFunSuite & Checkers
"org.scalatest" %% "scalatest" % "3.2.9" % Test,
// Arbitrary & Gen
"org.scalacheck" %% "scalacheck" % "1.17.0" % Test,
// OrderTests
"org.typelevel" %% "cats-laws" % "2.9.0" % Test,

Maintainers of each of the libraries have put enourmous work into make their code base Scala 3 compatible.

We will need discipline-scalatest and scalatest in order to extend our tests with AnyFunSuite, FunSuiteDiscipline, and Checkers. Next, we follow up with including scalacheck and cats-laws so we can make use of Arbitrary, Gen, and OrderTests. Note that from Scala 2 to Scala 3 FunSuite became AnyFunSuite and Discipline became FunSuiteDiscipline.

Defining Tests

Using AnyFunSuite gives us the ability to write this styles of tests:

import org.scalatest.funsuite.AnyFunSuite
import org.typelevel.discipline.scalatest.FunSuiteDiscipline
import org.scalatestplus.scalacheck.Checkers

class UserSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers {
  test("my special test") {
    assert(???, ???)
  }
}

For our purposes we’ll be using checkAll() instead of test(), so you can remove the test above and replace it with:

checkAll(
  "test User satisfies order properties",
  OrderTests[User].order
)

checkAll will generate tons of different variations of instances of the User case class and make sure the order properties holds for all of those instances. At this point you can run your test, but the scala compiler will complain that you are missing some implicits. Read on for how to resolve these compiler errors.

Add Implicits/Givens

The User data type has to two parameters, we would like to make sure that Users satisfy the total order property in the second parameter, age. To do this we provide an implicit Ordering and then we form an implicit Order with the ordering.

In order for checkAll to generate arbitaray Users it needs to look for a way to do so via an implicit search. To define some arbitraries we use Gen. Gen generates arbitrary Strings and Ints to instantiate Users. This testing technique is better than concocting your handfull of Users istances by hand.

import cats.Order
import cats.kernel.laws.discipline.OrderTests

import org.scalacheck.Arbitrary
import org.scalacheck.Gen

class UserSpec extends AnyFunSuite with FunSuiteDiscipline with Checkers {

  given userOrdering: Ordering[User] = Ordering.by(_.age)
  given catsUserOrder: Order[User] = Order.fromOrdering(userOrdering)

  given UserArb: Arbitrary[User] = Arbitrary {
    for {
      name <- Gen.alphaStr
      age <- Gen.chooseNum(0, 120)
    } yield User(name, age)
  }

  given UserFuncArb: Arbitrary[User => User] = Arbitrary {
    for {
      user1 <- UserArb.arbitrary
      user2 <- UserArb.arbitrary
    } yield user1 => user2
  }

  checkAll(
    "test User satisfies order properties",
    OrderTests[User].order
  )
}

The first implicit, UserArb, is fairly straightforward, it just generates a random string for the name an a random number between 0 and 120 for the age and produces a User. UserFuncArb generates arbitrary functions from User to User, this is the second implicit parameter required by order.

Running Tests

Hop on over to your favorite terminal and run sbt test under this project. You should see some output like this:

[info] UserSpec:
[info] - test User satisfies order properties.order.antisymmetry
[info] - test User satisfies order properties.order.antisymmetry eq
[info] - test User satisfies order properties.order.compare
[info] - test User satisfies order properties.order.gt
[info] - test User satisfies order properties.order.gteqv
[info] - test User satisfies order properties.order.lt
[info] - test User satisfies order properties.order.max
[info] - test User satisfies order properties.order.min
[info] - test User satisfies order properties.order.partialCompare
[info] - test User satisfies order properties.order.pmax
[info] - test User satisfies order properties.order.pmin
[info] - test User satisfies order properties.order.reflexivity eq
[info] - test User satisfies order properties.order.reflexivity gt
[info] - test User satisfies order properties.order.reflexivity lt
[info] - test User satisfies order properties.order.symmetry eq
[info] - test User satisfies order properties.order.totality
[info] - test User satisfies order properties.order.transitivity
[info] - test User satisfies order properties.order.transitivity eq

You can now rest assured that your User data type satisfies the total order laws under the age parameter.

Conclusion

I have described how to write a test using Scala 3 compatible libraries discipline-scalatest, scalatest, scalacheck, and cats-laws.
The test checks an example User case class satisfies the total ordering properties in the age parameter. I strongly urge you to use cats-laws to verify your data types satisfy properties, instead of writing your own. Feel free to play around with this project as a starting point for your property based testing or to just tinker.