scalatypeclassscalatestproperty-based-testingtagless-final

In scala property based tests for tagless final with variable inerpreter


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 => ??? 
        
      }
    }
  }
}

Solution

  • 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.