Created
November 5, 2019 16:56
-
-
Save tomwadeson/e21ac57e8fde89011938c8f5dfdce4fa to your computer and use it in GitHub Desktop.
A demonstration of specifying a `FooRepository` capability and ensuring that in-memory and postgres-based interpreters are compliant
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package foo | |
import cats.effect.{IO, Resource, Sync} | |
import cats.effect.concurrent.Ref | |
import cats.implicits._ | |
import doobie.util.transactor.Transactor | |
/** | |
* The `Foo` domain model. | |
* | |
* We'd probably choose to separate the id from the rest of the record, but that's not important for our purposes here | |
*/ | |
final case class Foo(id: String, name: String, age: Int) | |
/** | |
* The `FooRepository` "algebra" or "capability". | |
*/ | |
trait FooRepository[F[_]] { | |
def create(foo: Foo): F[Unit] | |
def findById(id: String): F[Option[Foo]] | |
} | |
object FooRepository { | |
/** | |
* An in-memory/reference implementation of the `FooRepository` capability. | |
* | |
* Use this for: | |
* - testing code that depends on a `FooRepository` | |
* - demoing an early version of your product to get feedback before you're ready to deal with operational stuff | |
* | |
* On testing, you'll find this implementation useful but not sufficient to test your higher-level code: you'll | |
* probably also want a `StubFooRepository` that you can configure to inject failure or interesting behaviour so that | |
* you're able to test error handling and retry logic, etc. | |
* | |
* (Feel free to use some concrete effect type here instead of an `F[_] : Sync`—that's fine.) | |
* (Also feel free to define some nominal `InMemoryFooRepository` class elsewhere if you'd prefer to do that.) | |
*/ | |
def inMemory[F[_]: Sync]: F[FooRepository[F]] = | |
Ref[F] | |
.of(Map.empty[String, Foo]) | |
.map(foos => | |
new FooRepository[F] { | |
override def create(foo: Foo): F[Unit] = | |
foos.update(_ + (foo.id -> foo)) | |
override def findById(id: String): F[Option[Foo]] = | |
foos.get.map(_.get(id)) | |
}) | |
} | |
/** | |
* An implementation of `FooRepository` that uses doobie to interact with postgres | |
*/ | |
final class DoobiePostgresFooRepository[F[_]] private (xa: Transactor.Aux[F, Unit]) extends FooRepository[F] { | |
override def create(foo: Foo): F[Unit] = ??? | |
override def findById(id: String): F[Option[Foo]] = ??? | |
} | |
object DoobiePostgresFooRepository { | |
/** | |
* Assume this has all we need to create a `Transactor` | |
*/ | |
trait Config | |
def create[F[_]](config: Config): DoobiePostgresFooRepository[F] = | |
new DoobiePostgresFooRepository[F](null) | |
} | |
import org.scalatest.PropSpec | |
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks | |
import org.scalacheck.ScalacheckShapeless._ | |
/** | |
* A specification for our `FooRepository` capability. *ALL* `FooRepository` interpreters *must* be tested against this | |
* spec. | |
* | |
* Incidentally, interpreting directly in `IO` is *fine*! | |
* These tests represent the edge of the world; there's no benefit to be had from interpreting in `State` or whatever. | |
*/ | |
trait FooRepositorySpec extends PropSpec with ScalaCheckPropertyChecks { | |
property("`Foo`s are persisted") { | |
forAll { foo: Foo => | |
(makeFooRepository use { fooRepository => | |
for { | |
_ <- fooRepository.create(foo) | |
found <- fooRepository.findById(foo.id) | |
} yield { | |
assert(found contains foo) | |
} | |
}).unsafeRunSync() | |
} | |
} | |
property("`findById` is stable") { | |
forAll { foo: Foo => | |
(makeFooRepository use { fooRepository => | |
for { | |
_ <- fooRepository.create(foo) | |
found <- fooRepository.findById(foo.id).replicateA(20) | |
} yield { | |
assert(found.forall(_ contains foo)) | |
} | |
}).unsafeRunSync() | |
} | |
} | |
property("`Foo`s are unique by id") { | |
// ... | |
} | |
def makeFooRepository: Resource[IO, FooRepository[IO]] | |
} | |
/** | |
* There's nothing fancy required to test our in-memory interpreter. | |
* | |
* BTW: I'm of the opinion that this is a unit test! | |
*/ | |
class InMemoryFooRepositorySpec extends FooRepositorySpec { | |
override def makeFooRepository: Resource[IO, FooRepository[IO]] = | |
Resource.liftF(FooRepository.inMemory[IO]) | |
} | |
/** | |
* There's a bunch of stuff we likely need to do to test our postgres-based interpreter: spin up a Docker container; | |
* apply database migrations to build the schema; configure thread- and connection-pools, etc. | |
*/ | |
class DoobiePostgresFooRepositorySpec extends FooRepositorySpec { | |
/** | |
* Packaging all of this up in a `Resource` that's acquired and released for each test will be very slow. | |
* Either put up with it; embrace a bit of impurity (`unsafeRunSync` in `beforeAll` and `afterAll`); or experiment | |
* with https://github.com/kubukoz/flawless or ZIO Test. | |
*/ | |
override def makeFooRepository: Resource[IO, FooRepository[IO]] = | |
for { | |
postgresDocker <- makePostgresDocker | |
config <- makeConfiguration(postgresDocker) | |
_ <- Resource.liftF(applyDatabaseMigrations(config)) | |
} yield DoobiePostgresFooRepository.create[IO](config) | |
trait PostgresDocker | |
def makePostgresDocker: Resource[IO, PostgresDocker] = ??? | |
def makeConfiguration(postgresDocker: PostgresDocker): Resource[IO, DoobiePostgresFooRepository.Config] = ??? | |
def applyDatabaseMigrations(config: DoobiePostgresFooRepository.Config): IO[Unit] = ??? | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment