Septic is a model based property based testing library for tagless final algebras.
Improve the situation of testing repository and API clients.
Tagless final algebras can come in many forms, the scope of library is to offer utitilies to make testing of properties easier for repository and api tagless final algebras.
Typical properties tested with repositories and API clients are:
- Data loss - Does insert & update / read yield symmetric results?
- Locality - Do update / delete / read specific things?
- Idempotent - Are insert / update / delete methods idempotent?
Testing these properties can assert that the interactions with datastores and API's is correct.
Service code, is code which uses these algebras to orchestrate a certain flow. Using real implementations can be useful in an E2E test, but to test if the service code is orchestating correctly can yield tremendous amounts of slow E2E test. To avoid that unit tests which either work with mocks or in-memory variants are favored.
The downside of mocks is that you give them certain input and mock the output. What if this algebra would never return such an output with that input? Then your orchestration test is also invalid.
Due that reason I favor testing orchestrating logic with in-memory variants.
Simple tagless final repository
final case class Person(id: UUID, name: String, age: Int)
trait PersonRepository[F[_]] {
def create: F[Unit]
def insertMany(persons: List[Person]): F[Long]
def deleteWhenOlderThen(age: Long): F[Long]
def listAll(): F[List[Person]]
}
object PersonRepository {
implicit val functorK: FunctorK[PersonRepository] = Derive.functorK
implicit val semigroupalK: SemigroupalK[PersonRepository] = Derive.semigroupalK
}
An in-memory implementation using Septic
, an specialized State
monad:
@Lenses
final case class Universe(
persons: List[Person]
)
object Universe {
def zero: Universe = Universe(Nil)
}
object SepticPersonRepository extends PersonRepository[Septic[Universe, *]] {
def insertMany(persons: List[Person]): Septic[Universe, Long] =
Septic.insertMany(Universe.persons)(persons)
def deleteWhenOlderThen(age: Long): Septic[Universe, Long] =
Septic.delete(Universe.persons)(_.age > age)
def listAll(): Septic[Universe, List[Person]] =
Septic.all(Universe.persons)
def create: Septic[Universe, Unit] =
Septic.unit
}
A doobie implementation
object DoobiePersonRepository extends PersonRepository[ConnectionIO] {
object queries {
def deleteWhenOlderThen(age: Long): Update0 =
fr"delete from persons where age > $age".update
def create =
fr"""create table if not exists persons (
| id uuid primary key,
| name text not null,
| age numeric not null
|)""".stripMargin.update
def listAll: Query0[Person] =
fr"select id, name, age from persons".query[Person]
}
def insertMany(persons: List[Person]): ConnectionIO[Long] =
Update[Person]("insert into persons (id, name, age) values (?, ?, ?)").updateMany(persons).map(_.toLong)
def deleteWhenOlderThen(age: Long): ConnectionIO[Long] =
queries.deleteWhenOlderThen(age).run.map(_.toLong)
def listAll(): ConnectionIO[List[Person]] =
queries.listAll.to[List]
def create: doobie.ConnectionIO[Unit] =
queries.create.run.void
}
A simple spec, to verify that there is
- No data loss
- Deletes are specific to a certain age (locality)
class PersonRepoSpec extends Specification with DoobieSpec {
def harnass: Harnass[PersonRepository, IO, ConnectionIO, Universe] =
new Harnass(Universe.zero, DoobiePersonRepository, SepticPersonRepository, xa.trans)
"PersonRepository" should {
"should insert and read" in {
prop { persons: List[Person] =>
assertMirroring {
harnass.model.eval { x =>
x.create *>
x.insertMany(persons) *>
x.listAll()
}
}
}
}
"should delete people older then" in {
prop { (persons: List[Person], age: Int) =>
assertMirroring {
harnass.model.eval { x =>
x.create *>
x.insertMany(persons) *>
x.deleteWhenOlderThen(age) *>
x.listAll()
}
}
}
}
}
}