Testing

This document implements a simple org.http4s.HttpRoutes and then walks through the results (i.e. org.http4s.Response) of applying inputs (i.e. org.http4s.Request) to it.

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.HttpRoutes.

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._

If you're in a REPL, we also need a runtime:

import cats.effect.unsafe.IORuntime
implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global
case class User(name: String, age: Int)
implicit val UserEncoder: Encoder[User] = deriveEncoder[User]
// UserEncoder: Encoder[User] = io.circe.generic.encoding.DerivedAsObjectEncoder$$anon$1@51a0ef93

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

def httpRoutes[F[_]](repo: UserRepo[F])(
      implicit F: Async[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@2e26bce3

val response: IO[Response[IO]] = httpRoutes[IO](success).orNotFound.run(
  Request(method = Method.GET, uri = uri"/user/not-used" )
)
// response: IO[Response[IO]] = Map(
//   ioe = FlatMap(
//     ioe = Pure(value = ()),
//     f = cats.syntax.FlatMapOps$$$Lambda$20236/1093018091@6f6d14cd,
//     event = cats.effect.tracing.TracingEvent$StackTrace
//   ),
//   f = cats.data.OptionT$$Lambda$20670/2104837844@6903fe7c,
//   event = cats.effect.tracing.TracingEvent$StackTrace
// )

val expectedJson = Json.obj(
      "name" := "johndoe",
      "age" := 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@3f0958b8

val respFoundNone: IO[Response[IO]] = httpRoutes[IO](foundNone).orNotFound.run(
  Request(method = Method.GET, uri = uri"/user/not-used" )
)
// respFoundNone: IO[Response[IO]] = Map(
//   ioe = FlatMap(
//     ioe = Pure(value = ()),
//     f = cats.syntax.FlatMapOps$$$Lambda$20236/1093018091@24d32a1e,
//     event = cats.effect.tracing.TracingEvent$StackTrace
//   ),
//   f = cats.data.OptionT$$Lambda$20670/2104837844@231f8935,
//   event = cats.effect.tracing.TracingEvent$StackTrace
// )

check[Json](respFoundNone, 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@55997334

val respNotFound: IO[Response[IO]] = httpRoutes[IO](doesNotMatter).orNotFound.run(
  Request(method = Method.GET, uri = uri"/not-a-matching-path" )
)
// respNotFound: IO[Response[IO]] = Map(
//   ioe = FlatMap(
//     ioe = Pure(value = ()),
//     f = cats.syntax.FlatMapOps$$$Lambda$20236/1093018091@20a03da7,
//     event = cats.effect.tracing.TracingEvent$StackTrace
//   ),
//   f = cats.data.OptionT$$Lambda$20670/2104837844@1d1e6077,
//   event = cats.effect.tracing.TracingEvent$StackTrace
// )

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

Using client

Having HttpApp you can build a client for testing purposes. Following the example above we could define our HttpApp like this:

val httpApp: HttpApp[IO] = httpRoutes[IO](success).orNotFound
// httpApp: HttpApp[IO] = Kleisli(
//   run = org.http4s.syntax.KleisliResponseOps$$Lambda$19980/1403483203@421714c0
// )

From this, we can obtain the Client instance using Client.fromHttpApp and then use it to test our sever/app.

import org.http4s.client.Client

val request: Request[IO] = Request(method = Method.GET, uri = uri"/user/not-used")
// request: Request[IO] = (
//    = GET,
//    = Uri(
//     scheme = None,
//     authority = None,
//     path = /user/not-used,
//     query = ,
//     fragment = None
//   ),
//    = HttpVersion(major = 1, minor = 1),
//    = Headers(),
//    = Entity.Empty,
//    = org.typelevel.vault.Vault@5c09cf65
// )
val client: Client[IO] = Client.fromHttpApp(httpApp)
// client: Client[IO] = org.http4s.client.Client$$anon$3@7d658c46

val resp: IO[Json]     = client.expect[Json](request)
// resp: IO[Json] = FlatMap(
//   ioe = Map(
//     ioe = Delay(
//       thunk = cats.effect.IO$$$Lambda$20118/1113416755@13964645,
//       event = cats.effect.tracing.TracingEvent$StackTrace
//     ),
//     f = org.http4s.client.Client$$$Lambda$21802/1435125536@69ddf1e3,
//     event = cats.effect.tracing.TracingEvent$StackTrace
//   ),
//   f = cats.effect.kernel.Resource$$Lambda$20434/1444324292@7bbd695a,
//   event = cats.effect.tracing.TracingEvent$StackTrace
// )
assert(resp.unsafeRunSync() == expectedJson)

Conclusion

The above documentation demonstrated how to define an HttpRoutes[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