Testing

This document implements a simple org.http4s.HttpRoutes and then walk through the results of applying inputs, i.e. org.http4s.Request, to the service, i.e. org.http4s.HttpService.

After reading this doc, the reader should feel comfortable writing a unit test using his/her favorite Scala testing library.

Now, let's define an org.http4s.HttpService.

import cats.syntax.all._
import io.circe._
import io.circe.syntax._
import io.circe.generic.semiauto._
import cats.effect._
import org.http4s._
import org.http4s.circe._
import org.http4s.dsl.io._
import org.http4s.implicits._
case class User(name: String, age: Int)
implicit val UserEncoder: Encoder[User] = deriveEncoder[User]
// UserEncoder: Encoder[User] = io.circe.generic.encoding.DerivedAsObjectEncoder$$anon$1@1ef0f50f

trait UserRepo[F[_]] {
  def find(userId: String): F[Option[User]]
}

def service[F[_]](repo: UserRepo[F])(
      implicit F: Effect[F]
): HttpRoutes[F] = HttpRoutes.of[F] {
  case GET -> Root / "user" / id =>
    repo.find(id).map {
      case Some(user) => Response(status = Status.Ok).withEntity(user.asJson)
      case None       => Response(status = Status.NotFound)
    }
}

For testing, let's define a check function:

// Return true if match succeeds; otherwise false
def check[A](actual:        IO[Response[IO]],
            expectedStatus: Status,
            expectedBody:   Option[A])(
    implicit ev: EntityDecoder[IO, A]
): Boolean =  {
   val actualResp         = actual.unsafeRunSync()
   val statusCheck        = actualResp.status == expectedStatus
   val bodyCheck          = expectedBody.fold[Boolean](
       // Verify Response's body is empty.
       actualResp.body.compile.toVector.unsafeRunSync().isEmpty)(
       expected => actualResp.as[A].unsafeRunSync() == expected
   )
   statusCheck && bodyCheck
}

Let's define service by passing a UserRepo that returns Ok(user).

val success: UserRepo[IO] = new UserRepo[IO] {
  def find(id: String): IO[Option[User]] = IO.pure(Some(User("johndoe", 42)))
}
// success: UserRepo[IO] = repl.MdocSession$App$$anon$2@3a6df794

val response: IO[Response[IO]] = service[IO](success).orNotFound.run(
  Request(method = Method.GET, uri = uri"/user/not-used" )
)
// response: IO[Response[IO]] = Map(
//   source = Suspend(
//     thunk = org.http4s.HttpRoutes$$$Lambda$22295/1597271665@e909e20,
//     trace = StackTrace(
//       stackTrace = List(
//         cats.effect.internals.IOTracing$.buildFrame(IOTracing.scala:48),
//         cats.effect.internals.IOTracing$.buildCachedFrame(IOTracing.scala:39),
//         cats.effect.internals.IOTracing$.cached(IOTracing.scala:34),
//         cats.effect.IO$.defer(IO.scala:1157),
//         cats.effect.IOLowPriorityInstances$IOEffect.suspend(IO.scala:921),
//         cats.effect.IOLowPriorityInstances$IOEffect.suspend(IO.scala:865),
//         cats.effect.Sync.defer(Sync.scala:48),
//         cats.effect.Sync.defer$(Sync.scala:48),
//         cats.effect.IOLowPriorityInstances$IOEffect.defer(IO.scala:865),
//         org.http4s.HttpRoutes$.$anonfun$of$1(HttpRoutes.scala:80),
//         org.http4s.syntax.KleisliResponseOps.$anonfun$orNotFound$1(KleisliSyntax.scala:51),
//         org.http4s.blaze.server.Http1ServerStage.$anonfun$raceTimeout$4(Http1ServerStage.scala:396),
//         org.http4s.blaze.server.Http1ServerStage$$anon$2.$anonfun$run$1(Http1ServerStage.scala:221),
//         cats.effect.internals.IORunLoop$.liftedTree1$1(IORunLoop.scala:123),
//         cats.effect.internals.IORunLoop$.cats$effect$internals$IORunLoop$$loop(IORunLoop.scala:118),
//         cats.effect.internals.IORunLoop$RestartCallback.signal(IORunLoop.scala:480),
//         cats.effect.internals.IORunLoop$RestartCallback.apply(IORunLoop.scala:501),
//         cats.effect.internals.IORunLoop$RestartCallback.apply(IORunLoop.scala:439),
//         cats.effect.internals.IOShift$Tick.run(IOShift.scala:36),
//         java.util.concurrent.ForkJoinTask$RunnableExecuteAction.exec(ForkJoinTask.java:1402),
//         java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289),
//         java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1056),
//         java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692),
//         java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:175)
//       )
//     )
//   ),
//   f = cats.data.OptionT$$Lambda$22297/1248283906@1893519c,
//   trace = StackTrace(
//     stackTrace = List(
//       cats.effect.internals.IOTracing$.buildFrame(IOTracing.scala:48),
//       cats.effect.internals.IOTracing$.buildCachedFrame(IOTracing.scala:39),
//       cats.effect.internals.IOTracing$.cached(IOTracing.scala:34),
//       cats.effect.IO.map(IO.scala:106),
// ...

val expectedJson = Json.obj(
  ("name", Json.fromString("johndoe")),
  ("age",  Json.fromBigInt(42))
)
// expectedJson: Json = JObject(value = object[name -> "johndoe",age -> 42])

check[Json](response, Status.Ok, Some(expectedJson))
// res0: Boolean = true

Next, let's define a service with a userRepo that returns None to any input.

val foundNone: UserRepo[IO] = new UserRepo[IO] {
  def find(id: String): IO[Option[User]] = IO.pure(None)
}
// foundNone: UserRepo[IO] = repl.MdocSession$App$$anon$3@1d2870c

val response: IO[Response[IO]] = service[IO](foundNone).orNotFound.run(
  Request(method = Method.GET, uri = uri"/user/not-used" )
)
// response: IO[Response[IO]] = Map(
//   source = Suspend(
//     thunk = org.http4s.HttpRoutes$$$Lambda$22295/1597271665@54d601f5,
//     trace = StackTrace(
//       stackTrace = List(
//         cats.effect.internals.IOTracing$.buildFrame(IOTracing.scala:48),
//         cats.effect.internals.IOTracing$.buildCachedFrame(IOTracing.scala:39),
//         cats.effect.internals.IOTracing$.cached(IOTracing.scala:34),
//         cats.effect.IO$.defer(IO.scala:1157),
//         cats.effect.IOLowPriorityInstances$IOEffect.suspend(IO.scala:921),
//         cats.effect.IOLowPriorityInstances$IOEffect.suspend(IO.scala:865),
//         cats.effect.Sync.defer(Sync.scala:48),
//         cats.effect.Sync.defer$(Sync.scala:48),
//         cats.effect.IOLowPriorityInstances$IOEffect.defer(IO.scala:865),
//         org.http4s.HttpRoutes$.$anonfun$of$1(HttpRoutes.scala:80),
//         org.http4s.syntax.KleisliResponseOps.$anonfun$orNotFound$1(KleisliSyntax.scala:51),
//         org.http4s.blaze.server.Http1ServerStage.$anonfun$raceTimeout$4(Http1ServerStage.scala:396),
//         org.http4s.blaze.server.Http1ServerStage$$anon$2.$anonfun$run$1(Http1ServerStage.scala:221),
//         cats.effect.internals.IORunLoop$.liftedTree1$1(IORunLoop.scala:123),
//         cats.effect.internals.IORunLoop$.cats$effect$internals$IORunLoop$$loop(IORunLoop.scala:118),
//         cats.effect.internals.IORunLoop$RestartCallback.signal(IORunLoop.scala:480),
//         cats.effect.internals.IORunLoop$RestartCallback.apply(IORunLoop.scala:501),
//         cats.effect.internals.IORunLoop$RestartCallback.apply(IORunLoop.scala:439),
//         cats.effect.internals.IOShift$Tick.run(IOShift.scala:36),
//         java.util.concurrent.ForkJoinTask$RunnableExecuteAction.exec(ForkJoinTask.java:1402),
//         java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289),
//         java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1056),
//         java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692),
//         java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:175)
//       )
//     )
//   ),
//   f = cats.data.OptionT$$Lambda$22297/1248283906@5e3de4d1,
//   trace = StackTrace(
//     stackTrace = List(
//       cats.effect.internals.IOTracing$.buildFrame(IOTracing.scala:48),
//       cats.effect.internals.IOTracing$.buildCachedFrame(IOTracing.scala:39),
//       cats.effect.internals.IOTracing$.cached(IOTracing.scala:34),
//       cats.effect.IO.map(IO.scala:106),
// ...

check[Json](response, Status.NotFound, None)
// res1: Boolean = true

Finally, let's pass a Request which our service does not handle.

val doesNotMatter: UserRepo[IO] = new UserRepo[IO] {
  def find(id: String): IO[Option[User]] =
    IO.raiseError(new RuntimeException("Should not get called!"))
}
// doesNotMatter: UserRepo[IO] = repl.MdocSession$App$$anon$4@5ca0413b

val response: IO[Response[IO]] = service[IO](doesNotMatter).orNotFound.run(
  Request(method = Method.GET, uri = uri"/not-a-matching-path" )
)
// response: IO[Response[IO]] = Map(
//   source = Suspend(
//     thunk = org.http4s.HttpRoutes$$$Lambda$22295/1597271665@49df5744,
//     trace = StackTrace(
//       stackTrace = List(
//         cats.effect.internals.IOTracing$.buildFrame(IOTracing.scala:48),
//         cats.effect.internals.IOTracing$.buildCachedFrame(IOTracing.scala:39),
//         cats.effect.internals.IOTracing$.cached(IOTracing.scala:34),
//         cats.effect.IO$.defer(IO.scala:1157),
//         cats.effect.IOLowPriorityInstances$IOEffect.suspend(IO.scala:921),
//         cats.effect.IOLowPriorityInstances$IOEffect.suspend(IO.scala:865),
//         cats.effect.Sync.defer(Sync.scala:48),
//         cats.effect.Sync.defer$(Sync.scala:48),
//         cats.effect.IOLowPriorityInstances$IOEffect.defer(IO.scala:865),
//         org.http4s.HttpRoutes$.$anonfun$of$1(HttpRoutes.scala:80),
//         org.http4s.syntax.KleisliResponseOps.$anonfun$orNotFound$1(KleisliSyntax.scala:51),
//         org.http4s.blaze.server.Http1ServerStage.$anonfun$raceTimeout$4(Http1ServerStage.scala:396),
//         org.http4s.blaze.server.Http1ServerStage$$anon$2.$anonfun$run$1(Http1ServerStage.scala:221),
//         cats.effect.internals.IORunLoop$.liftedTree1$1(IORunLoop.scala:123),
//         cats.effect.internals.IORunLoop$.cats$effect$internals$IORunLoop$$loop(IORunLoop.scala:118),
//         cats.effect.internals.IORunLoop$RestartCallback.signal(IORunLoop.scala:480),
//         cats.effect.internals.IORunLoop$RestartCallback.apply(IORunLoop.scala:501),
//         cats.effect.internals.IORunLoop$RestartCallback.apply(IORunLoop.scala:439),
//         cats.effect.internals.IOShift$Tick.run(IOShift.scala:36),
//         java.util.concurrent.ForkJoinTask$RunnableExecuteAction.exec(ForkJoinTask.java:1402),
//         java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289),
//         java.util.concurrent.ForkJoinPool$WorkQueue.runTask(ForkJoinPool.java:1056),
//         java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692),
//         java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:175)
//       )
//     )
//   ),
//   f = cats.data.OptionT$$Lambda$22297/1248283906@2d622e23,
//   trace = StackTrace(
//     stackTrace = List(
//       cats.effect.internals.IOTracing$.buildFrame(IOTracing.scala:48),
//       cats.effect.internals.IOTracing$.buildCachedFrame(IOTracing.scala:39),
//       cats.effect.internals.IOTracing$.cached(IOTracing.scala:34),
//       cats.effect.IO.map(IO.scala:106),
// ...

check[String](response, Status.NotFound, Some("Not found"))
// res2: Boolean = true

Conclusion

The above documentation demonstrated how to define an HttpService[F], pass Request's, and then test the expected Response.

To add unit tests in your chosen Scala Testing Framework, please follow the above examples.

References