0

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 => ??? 
        
      }
    }
  }
}
nashter
  • 1,181
  • 1
  • 15
  • 33
  • I would just make the tests expect a `Repository[F]` and then call the tests with different interpreters manually. Internally the tests can use property based testing. – Luis Miguel Mejía Suárez May 21 '23 at 16:03
  • IMHO you don't need to be DRY in tests. It will make your tests as complex as your code if you want to be generic for various implementation. Plus I guess you don't have that many implementations. Unless you're building a library but doesn't look like it. I would even challenge the need of any genericity at all in the 1st place given your example. – Gaël J May 21 '23 at 16:07
  • I am just practicing the idea of property based testing with different interpreters. Say, during my development time, I just need an `InMemoryRepository` but production will get a `RedisRepository`. But the laws pertaining to repository would be same. I am trying to put the laws based on properties (of a repository, in this case) in a trait. Later an InMemoryRepoSpec and RedisRepoSpec would verify if they satisfy the laws. @GaëlJ – nashter May 21 '23 at 16:15
  • Could you please help with a simple example? Kind of stuck – nashter May 21 '23 at 20:10

1 Answers1

2

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.

MartinHH
  • 982
  • 4
  • 7
  • Thanks. I understood. Is it too much to consider if the BuggyXXXUserRepositorySpec really contains the repo instance ? What is the relationship, in general between the laws and algebra? I presumed Algebra has laws. Also, I was trying with trait and compiler version 2.13 fails with context bound type on trait. – nashter May 24 '23 at 19:23
  • I added some sentences about trait vs abstract class. – MartinHH May 25 '23 at 04:49
  • I won't try to answer the question about the relationship between laws and alebra here because imho, it goes way beyond the scope of the original question and stack overflow questions should be "one focused questions at a time". – MartinHH May 25 '23 at 04:53
  • One more thing: to me, it seems rather unusual to use scalatest WordSpec and its style of testing (using side-effcting assertions instead of functionally returning a test result) for implementing "laws". I'd suggest to look into the various "tagless final & testing against laws" tutorials out there and try to use one of the testing frameworks that they use. – MartinHH May 25 '23 at 04:59
  • Thanks for the pointer. Will research on it. – nashter May 25 '23 at 12:48