I have the following algebra
// domain
case class User(id: String, name: String, age: Int)
// algebra
trait UserRepositoryAlgebra[F[_]] {
def createUser(user: User): F[Unit]
def getUser(userId: String): F[Option[User]]
}
I have a InMemoryInterpreter for development cycle. There would be more interpreters coming up in time. My intention is to attempt scalatest with property based tests and not bind with any specific interpreter. Basically, there needs to be laws/properties for UserRepository that every interpreter should satisfy.
I could come up with one as
trait UserRepositorySpec_1 extends AnyWordSpec with Matchers with ScalaCheckPropertyChecks {
"UserRepository" must {
"create and retrieve users" in {
//problem: this is tightly coupling with a specific interpreter. How to test RedisUserRepositoryInterpreter, for example, follwing DRY ?
val repo = new InMemoryUserRepository[IO]
val userGen: Gen[User] = for {
id <- Gen.alphaNumStr
name <- Gen.alphaNumStr
age <- Gen.posNum[Int]
} yield User(id, name, age)
forAll(userGen) { user =>
(for {
_ <- repo.createUser(user)
mayBeUser <- repo.getUser(user.id)
} yield mayBeUser).unsafeRunSync() must be(Option(user))
}
}
}
}
I have something like this in mind.
trait UserRepositorySpec[F[_]] extends AnyWordSpec with Matchers with ScalaCheckPropertyChecks {
import generators._
def repo: UserRepositoryAlgebra[F]
"UserRepository" must {
"create and find users" in {
forAll(userGen){ user => ???
}
}
}
}
How about this:
import org.scalacheck.Gen
import org.scalatest.wordspec.AnyWordSpec
import org.scalatest.matchers.must._
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
import cats.implicits._
import cats._
abstract class AbstractUserRepositorySpec[F[_] : FlatMap](repo: UserRepositoryAlgebra[F])
extends AnyWordSpec
with Matchers
with ScalaCheckPropertyChecks {
protected def run[A](value: F[A]): A
"UserRepository" must {
"create and retrieve users" in {
val userGen: Gen[User] = for {
id <- Gen.alphaNumStr
name <- Gen.alphaNumStr
age <- Gen.posNum[Int]
} yield User(id, name, age)
forAll(userGen) { user =>
val result: F[Option[User]] =
for {
_ <- repo.createUser(user)
mayBeUser <- repo.getUser(user.id)
} yield mayBeUser
run(result) must be(Option(user))
}
}
}
}
Now, you can do this (using cats.IO
as effect):
import cats.effect._
// buggy on purpose because we want to see the test fail
class BuggyIOUserRepository extends UserRepositoryAlgebra[IO] {
def createUser(user: User): IO[Unit] = IO.unit
def getUser(userId: String): IO[Option[User]] = IO.none
}
class BuggyIOUserRepositorySpec
extends AbstractUserRepositorySpec(new BuggyIOUserRepository) {
protected def run[A](value: IO[A]): A = {
import cats.effect.unsafe.implicits.global
value.unsafeRunSync()
}
}
But you could also do this (using cats.Id
as "pseudo-effect"):
// buggy on purpose because we want to see the test fail
class BuggyIdUserRepository extends UserRepositoryAlgebra[Id] {
def createUser(user: User): Id[Unit] = ()
def getUser(userId: String): Id[Option[User]] = None
}
class BuggyIdUserRepositorySpec
extends AbstractUserRepositorySpec(new BuggyIdUserRepository) {
protected def run[A](value: Id[A]): A = value
}
If something from the code is unclear to you, feel free to ask in comments.
Addendum: abstract class vs trait I used and abstract class because (at least in scala 2.x), traits cannot have constructor parameters and (at least for me) it is much more convenient to pass the implicit FlatMap
instance as constructor parameter (and once one uses an abstract class, why not pass the repo
under test as well). Also, it requires less care regarding initialization order.
I you prefer to use a trait, you could do it like this:
trait AbstractUserRepositorySpec[F[_]]
extends AnyWordSpec
with Matchers
with ScalaCheckPropertyChecks {
protected def repo: UserRepositoryAlgebra[F]
protected implicit flatMapForF: FlatMap[F]
protected def run[A](value: F[A]): A
"UserRepository" must {
// ...
}
But this approach will require a bit of care when providing the FlatMap[F]
:
You might be tempted to do override protected implicit flatMapForF: FlatMap[F] = implicitly
, but that would lead to an endless loop in implicit resolution. The abstract class variant avoids such caveats.