Extras - Testing Tools for Cats
StubToolsCats.stub
StubToolsCats.stub
is a tool for a stub (a simple function for testing) so that you don't need to use mock frameworks.
import cats.MonadThrow
import cats.syntax.all._
import extras.testing.StubToolsCats
def fooStub[F[_]: MonadThrow](f: => Option[A]): FooStub[F] = new FooStub[F] {
def foo: F[A] = StubToolsCats.stub(f) // F[A]
}
// If f is None, it will raise MissingStubException with the line number pointing where it's missing
def fooStub[F[_]: MonadThrow](f: => Option[() => A]): FooStub[F] = new FooStub[F] {
def foo(): F[A] = StubToolsCats.stub(f).map(_()) // F[A]
}
// If f is None, it will raise MissingStubException with the line number pointing where it's missing
def fooStub[F[_]: MonadThrow](f: => Option[A => B]): FooStub[F] = new FooStub[F] {
def foo(a: A): F[B] = StubToolsCats.stub(f).map(_(a)) // F[B]
}
// If f is None, it will raise MissingStubException with the line number pointing where it's missing
def fooStub[F[_]: MonadThrow](f: => Option[A => F[B]]): FooStub[F] = new FooStub[F] {
def foo(a: A): F[B] = StubToolsCats.stub(f).flatMap(_(a)) // F[B]
}
// If f is None, it will raise MissingStubException with the line number pointing where it's missing
Example
e.g.)
import cats.{Monad, MonadThrow}
import cats.syntax.all._
import eu.timepit.refined.types.all._
import eu.timepit.refined.cats._
import eu.timepit.refined.auto._
import io.estatico.newtype.macros.newtype
import extras.testing.StubToolsCats
object types {
@newtype case class Id(value: PosInt)
@newtype case class Name(value: NonEmptyString)
}
import types._
trait MyService[F[_]] {
def getName(id: Id): F[Option[Name]]
}
object MyServiceStub {
def apply[F[_]: MonadThrow](f: => Option[Id => F[Option[Name]]]): MyService[F] = new MyService[F] {
override def getName(id: Id): F[Option[Name]] = StubToolsCats.stub(f).flatMap(_(id))
}
}
class Hello[F[_]: Monad](myService: MyService[F]) {
def hello(id: Id): F[String] = {
myService.getName(id)
.map { maybeName =>
maybeName.fold(s"No name found for id ${id.value}")(name => s"Hello ${name.value}")
}
}
}
val expectedId = Id(1)
// expectedId: Id = 1
val expectedName = Name("Kevin")
// expectedName: Name = Kevin
import cats.effect._
val myService: MyService[IO] = MyServiceStub(((id: Id) =>
if (id.value === expectedId.value)
IO.pure(expectedName.some)
else
IO.pure(none)
).some)
// myService: MyService[IO] = repl.MdocSession$MdocApp0$MyServiceStub$$anon$1@77b4e617
val hello = new Hello[IO](myService)
// hello: Hello[IO] = repl.MdocSession$MdocApp0$Hello@61cadbe4
hello.hello(Id(1))
.map(println)
.unsafeRunSync()
// Hello Kevin
hello.hello(Id(2))
.map(println)
.unsafeRunSync()
// No name found for id 2
/* If you don't expect Hello to use MyService.getName,
* you can simply remove feeding the function for that operation
* and StubTools let you know where it fails if Hello uses MySerivce.getName.
*/
val myService2: MyService[IO] = MyServiceStub(none)
// myService2: MyService[IO] = repl.MdocSession$MdocApp0$MyServiceStub$$anon$1@d8cfd33
val hello2 = new Hello(myService2)
// hello2: Hello[[A]IO[A]] = repl.MdocSession$MdocApp0$Hello@488797ef
hello2.hello(Id(1))
.attempt
.map(println)
.unsafeRunSync()
// Left(extras.testing.StubTools$MissingStubException:
// >> Missing Stub implementation at
// >> repl.MdocSession$MdocApp0$MyServiceStub$$anon$1$$anonfun$getName$1.apply(cats.md:48)
// >> ---
// >> Details:
// >> repl.MdocSession$MdocApp0$MyServiceStub$$anon$1$$anonfun$getName$1.apply(cats.md:48)
// at repl.MdocSession$MdocApp0$MyServiceStub$$anon$1$$anonfun$getName$1.apply(cats.md:48)
// at cats.ApplicativeError.fromOption(ApplicativeError.scala:318)
// at cats.ApplicativeError.fromOption$(ApplicativeError.scala:315)
// at cats.effect.IOLowPriorityInstances$IOEffect.fromOption(IO.scala:865)
// at extras.testing.StubToolsCats$StubToolsCatsPartiallyApplied$.$anonfun$apply$1(StubToolsCats.scala:25)
// at cats.effect.internals.IORunLoop$.step(IORunLoop.scala:319)
// at cats.effect.IO.unsafeRunTimed(IO.scala:338)
// at cats.effect.IO.unsafeRunSync(IO.scala:256)
// at repl.MdocSession$MdocApp0$.<clinit>(cats.md:113)
// at repl.MdocSession$MdocApp.<init>(cats.md:5)
// at repl.MdocSession$.app(cats.md:3)
// at mdoc.internal.document.DocumentBuilder$$doc$.$anonfun$build$2(DocumentBuilder.scala:89)
// at scala.runtime.java8.JFunction0$mcV$sp.apply(JFunction0$mcV$sp.scala:18)
// at scala.util.DynamicVariable.withValue(DynamicVariable.scala:59)
// at scala.Console$.withErr(Console.scala:193)
// at mdoc.internal.document.DocumentBuilder$$doc$.$anonfun$build$1(DocumentBuilder.scala:89)
// at scala.runtime.java8.JFunction0$mcV$sp.apply(JFunction0$mcV$sp.scala:18)
// at scala.util.DynamicVariable.withValue(DynamicVariable.scala:59)
// at scala.Console$.withOut(Console.scala:164)
// at mdoc.internal.document.DocumentBuilder$$doc$.build(DocumentBuilder.scala:88)
// at mdoc.internal.markdown.MarkdownBuilder$.$anonfun$buildDocument$2(MarkdownBuilder.scala:48)
// at mdoc.internal.markdown.MarkdownBuilder$$anon$1.run(MarkdownBuilder.scala:105)
// )
NOTE
Why not just use mock framework for convenience? To answer that, please read Pitfalls of Mocking in tests from Xebia Functional (formerly known as 47 Degrees)
Besides what the blog tells you, mock frameworks often make you do bad practice like testing the implementation details with verify
.
There is also an issue when your mock is not correctly set. You may get a NullPointerException
for that, but it doesn't tell you where it's from and why you get it.