Cats Law Based Testing in Scala 3
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.
- Ref1: https://www.signifytechnology.com/blog/2019/04/beyond-unit-tests-an-intro-to-property-and-law-testing-in-scala-by-daniel-sebban?source=google.com
- Ref2: https://typelevel.org/cats/typeclasses/lawtesting.html
- Ref3: http://chrisphelps.github.io/scala/2016/11/30/Cats-Law-Checking-With-Discipline/
- Ref4: https://blog.rockthejvm.com/semigroups-and-monoids-in-scala/
- Ref5: https://blog.rockthejvm.com/testing-styles-scalatest/
Background
When building applications you may require objects satisfy certain properties. For example:
- Ref1 talks about requiring a
User
case class satisify thetotal 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
andMoniods
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",
[User].order
OrderTests)
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 User
s 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 User
s it needs to look for a way to do so via an implicit search. To define some arbitraries we use Gen
. Gen
generates arbitrary String
s and Int
s to instantiate User
s. This testing technique is better than concocting your handfull of User
s 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 {
: Ordering[User] = Ordering.by(_.age)
given userOrdering: Order[User] = Order.fromOrdering(userOrdering)
given catsUserOrder
: Arbitrary[User] = Arbitrary {
given UserArbfor {
<- Gen.alphaStr
name <- Gen.chooseNum(0, 120)
age } yield User(name, age)
}
: Arbitrary[User => User] = Arbitrary {
given UserFuncArbfor {
<- UserArb.arbitrary
user1 <- UserArb.arbitrary
user2 } yield user1 => user2
}
checkAll(
"test User satisfies order properties",
[User].order
OrderTests)
}
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.