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._
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@3bb4657f
trait UserRepo[F[_]] {
def find(userId: String): F[Option[User]]
}
def service[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@12ce7500
val response: IO[Response[IO]] = service[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$22422/745358076@6bb56349,
// event = cats.effect.tracing.TracingEvent$StackTrace
// ),
// f = cats.data.OptionT$$Lambda$22927/1698301160@6b63341e,
// 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@73642b90
val respFoundNone: IO[Response[IO]] = service[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$22422/745358076@12e33689,
// event = cats.effect.tracing.TracingEvent$StackTrace
// ),
// f = cats.data.OptionT$$Lambda$22927/1698301160@520ca0b7,
// 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@203dbd2c
val respNotFound: IO[Response[IO]] = service[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$22422/745358076@72572a10,
// event = cats.effect.tracing.TracingEvent$StackTrace
// ),
// f = cats.data.OptionT$$Lambda$22927/1698301160@798b0dad,
// 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] = service[IO](success).orNotFound
// httpApp: HttpApp[IO] = Kleisli(
// run = org.http4s.syntax.KleisliResponseOps$$Lambda$22167/2118619545@4e350333
// )
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@32c0304
// )
val client: Client[IO] = Client.fromHttpApp(httpApp)
// client: Client[IO] = org.http4s.client.Client$$anon$3@48e0bedb
val resp: IO[Json] = client.expect[Json](request)
// resp: IO[Json] = FlatMap(
// ioe = Map(
// ioe = Delay(
// thunk = cats.effect.IO$$$Lambda$22305/351966819@69a7340f,
// event = cats.effect.tracing.TracingEvent$StackTrace
// ),
// f = org.http4s.client.Client$$$Lambda$24087/623713410@f6d9e93,
// event = cats.effect.tracing.TracingEvent$StackTrace
// ),
// f = cats.effect.kernel.Resource$$Lambda$22665/239848797@6cba8859,
// event = cats.effect.tracing.TracingEvent$StackTrace
// )
assert(resp.unsafeRunSync() == expectedJson)
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.