Integration library between MUnit and http4s.
Add the following line to your build.sbt file:
libraryDependencies += "com.alejandrohdezma" %% "http4s-munit" % "3.0.0" % Test| alejandrohdezma | gutiory | JackTreble |
This library provides a new type of suite (Http4sSuite) that you can use for
several things:
We can use the Http4Suite to write tests for an HttpRoutes using Request[IO] values easily:
import cats.effect.IO
import org.http4s._
class MyHttpRoutesSuite extends munit.Http4sSuite {
override def http4sMUnitClientResource = HttpRoutes.of[IO] {
case GET -> Root / "hello" => Ok("Hi")
case GET -> Root / "hello" / name => Ok(s"Hi $name")
}.orFail.asClient
test(GET(uri"hello" / "Jose")).alias("Say hello to Jose") { response =>
assertIO(response.as[String], "Hi Jose")
}
// You can also override routes per-test
test(GET(uri"hello" / "Jose"))
.withHttpApp(HttpRoutes.of[IO] { case GET -> Root / "hello" / _=> Ok("Hi") }.orFail)
.alias("Overriden routes") { response =>
assertIO(response.as[String], "Hi")
}
}The test method receives a Request[IO] object and when the test runs, it runs that request against the provided routes and let you assert the response.
The client comes from overriding
http4sMUnitClientResource(aResource[IO, Client[IO]]), built fresh per test by default — overridehttp4sMUnitClientTestFixture(for example withResourceSuiteLocalFixture) to share one client across the whole suite. The olderhttp4sMUnitClientFixtureandasFixtureare deprecated in favour ofhttp4sMUnitClientResourceandasClient.
http4s-munit will automatically name your tests using the information of the provided Request. For example, for the test shown in the previous code snippet, the following will be shown when running the test:
munit.MyHttpRoutesSuite:0s
+ GET -> hello/Jose (Say hello to Jose) 0.014s
If we want to test authenticated routes (AuthedRoutes in http4s) it will be
completely similar to the previous section, except that we need to ensure we
provide the context in the request. The library provides a couple methods to
simplify this: context and getContext.
For both of them you need to have an implicit Key[A] instance (being A
your context's type) in scope.
import cats.effect.IO
import org.http4s._
import org.typelevel.vault.Key
class MyAuthedRoutesSuite extends munit.Http4sSuite {
implicit val key: Key[String] = Key.newKey[IO, String].unsafeRunSync()
override def http4sMUnitClientResource = AuthedRequest.fromContext[String].andThen {
AuthedRoutes.of[String, IO] {
case GET -> Root / "hello" as user => Ok(s"$user: Hi")
case GET -> Root / "hello" / name as user => Ok(s"$user: Hi $name")
}
}.orFail.asClient
test(GET(uri"hello" / "Jose").context("alex")).alias("Say hello to Jose") { response =>
assertIO(response.as[String], "alex: Hi Jose")
}
// You can also override routes per-test
test(GET(uri"hello" / "Jose").context("alex"))
.withHttpApp {
AuthedRequest.fromContext[String]
.andThen(AuthedRoutes.of[String, IO] { case GET -> Root / "hello" / _ as _ => Ok("Hey") })
.orFail
}
.alias("Overriden routes") { response =>
assertIO(response.as[String], "Hey")
}
}If you just want to add tests for a class or algebra that uses a Client instance you can make your suite extend Http4sMUnitSyntax (it also requires extending CatsEffectSuite).
It includes a handful of utilities among which are two extension methods to the Client companion object: from and partialFixture.
Client.from lets you create a mocked client from a partial function representing routes:
import org.http4s.client.Client
class ClientSuiteSuite extends munit.CatsEffectSuite with munit.Http4sMUnitSyntax {
val client = Client.from {
case GET -> Root / "ping" => Ok("pong")
}
}On the other hand, the class also provides another extension method: Client.partialFixture. This method is inteded to be used to easily create a fixture for testing a class that uses an http4s' Client.
Given an algebra like:
import cats.effect._
import org.http4s.client.Client
trait PingService[F[_]] {
def ping(): F[String]
}
object PingService {
def create[F[_]: Async](client: Client[F]) =
new PingService[F] {
def ping(): F[String] = client.expect[String]("ping")
}
}You can test it using Http4sMUnitSyntax like:
import cats.effect._
import org.http4s.client.Client
class PingServiceSuite extends munit.CatsEffectSuite with munit.Http4sMUnitSyntax {
val fixture = Client.partialFixture(client => Resource.pure(PingService.create(client)))
fixture {
case GET -> Root / "ping" => Ok("pong")
}.test("PingService.ping works") { service =>
val result = service.ping()
assertIO(result, "pong")
}
}In the case you don't want to use static http4s routes, but a running HTTP server,
you just need to provide a real http4s' Client implementation under http4sMUnitClientResource.
Every test request you write will be made using this client.
import cats.effect.IO
import cats.effect.Resource
import io.circe.Json
import org.http4s.circe._
import org.http4s.client.Client
import org.http4s.ember.client.EmberClientBuilder
class GitHubSuite extends munit.Http4sSuite {
override def http4sMUnitClientResource: Resource[IO, Client[IO]] =
EmberClientBuilder.default[IO].build.map(_.withBaseUri(uri"https://api.github.com"))
test(GET(uri"users/gutiory")) { response =>
assertEquals(response.status.code, 200)
val result = response.as[Json].map(_.hcursor.get[String]("login"))
assertIO(result, Right("gutiory"))
}
}If you are making requests to the same server, you can override
http4sMUnitClientResourcelike:override def http4sMUnitClientResource: Resource[IO, Client[IO]] = EmberClientBuilder.default[IO].build.map(_.withBaseUri(localhost.withPort(8080)))
Testing a Docker container with TestContainers and http4s-munit is easy. You
just need to use TestCotnainersFixtures and use Http4sSuite to connect to
it:
import cats.effect.IO
import cats.effect.Resource
import com.dimafeng.testcontainers.GenericContainer
import com.dimafeng.testcontainers.munit.fixtures.TestContainersFixtures
import io.circe.Json
import org.http4s.client.Client
import org.http4s.circe._
import org.http4s.ember.client.EmberClientBuilder
class TestContainersSuite extends munit.Http4sSuite with TestContainersFixtures {
// There is also available `ForEachContainerFixture`
val container = ForAllContainerFixture {
GenericContainer(dockerImage = "mendhak/http-https-echo", exposedPorts = List(80))
}
override def munitFixtures = super.munitFixtures :+ container
override def http4sMUnitClientResource: Resource[IO, Client[IO]] =
EmberClientBuilder.default[IO].build.map(_.withBaseUri(localhost.withPort(container().mappedPort(80))))
test(GET(uri"ping")) { response =>
assertEquals(response.status.code, 200)
assertIOBoolean(response.as[Json].map(_.isObject))
}
}Or if you don't want to use container fixtures and you don't mind starting a container for each test:
import cats.effect.IO
import cats.effect.Resource
import cats.syntax.all._
import com.dimafeng.testcontainers.GenericContainer
import org.http4s.ember.client.EmberClientBuilder
class TestContainersSuite extends munit.Http4sSuite {
lazy val container = GenericContainer(dockerImage = "nginxdemos/hello", exposedPorts = List(80))
override def http4sMUnitClientResource =
Resource.fromAutoCloseable(IO(container.start()).as(container)) >>
EmberClientBuilder.default[IO].build.map(_.withBaseUri(localhost.withPort(container.mappedPort(80))))
test(GET(uri"ping")) { response =>
assertEquals(response.status.code, 200, response.clues)
}
}Sometimes (especially when testing against a real server) you need to run some setup before each
test — and tear it down afterwards. Register a ResourceTestLocalFixture in munitFixtures; it can
use the suite's configured client through http4sMUnitClient (the library sets the client
up first, so it is available there), and the test body then runs with that setup already in place.
import cats.effect.IO
import cats.effect.Resource
import cats.syntax.all._
import io.circe.Json
import io.circe.syntax._
import org.http4s.ember.client.EmberClientBuilder
import org.http4s.circe._
class MyBookstoreSuite extends munit.Http4sSuite {
override def http4sMUnitClientResource = EmberClientBuilder.default[IO].build
val book = ResourceTestLocalFixture(
"book",
Resource.make {
val newBook = Json.obj("name" := "The Lord Of The Rings")
http4sMUnitClient
.expect[Json](POST(newBook, uri"http://localhost:8080/books"))
.flatMap(_.hcursor.get[Int]("id").liftTo[IO])
}(id => http4sMUnitClient.run(DELETE(uri"http://localhost:8080/books" / id)).use_)
)
override def munitFixtures = super.munitFixtures :+ book
test(GET(uri"http://localhost:8080/books?q=Rings")) { response =>
assertEquals(response.status.code, 200, response.clues)
val result = response.as[Json].map(_.hcursor.get[String]("name"))
assertIO(result, Right("The Lord Of The Rings"), response.clues)
}
}Sometimes the request can only be built by running an effect — for example, when it depends on a value produced by a fixture, or on some setup that must run when the test starts (not when the suite is defined). For these cases test also accepts an IO[Request[IO]]:
import cats.effect.IO
import org.http4s._
class MyDeferredRequestSuite extends munit.Http4sSuite {
override def http4sMUnitClientResource = HttpRoutes.of[IO] {
case GET -> Root / "hello" / name => Ok(s"Hi $name")
}.orFail.asClient
def loadUser: IO[String] = IO.pure("Jose")
test(loadUser.map(user => GET(uri"hello" / user))).alias("Say hello to the loaded user") { response =>
assertIO(response.as[String], "Hi Jose")
}
}Since the request is not available while naming the test, providing an alias is mandatory; http4s-munit will fail with a clear message if you forget it.
The same Client[IO] used to run the request is available inside the test body — and inside any plain
test("...") body — by calling http4sMUnitClient. Because it is the very same instance,
stateful middleware (a CookieJar, a connection pool, ...) is shared between the request run by the
DSL and any manual call you make:
import cats.effect.IO
import org.http4s.HttpRoutes
class MyClientSuite extends munit.Http4sSuite {
override def http4sMUnitClientResource = HttpRoutes.of[IO] {
case GET -> Root / "ping" => Ok("pong")
case GET -> Root / "count" => Ok("42")
}.orFail.asClient
test(GET(uri"/ping")) { response =>
for {
_ <- assertIO(response.as[String], "pong")
_ <- assertIO(http4sMUnitClient.expect[String](GET(uri"/count")), "42")
} yield ()
}
test("the count endpoint works") {
assertIO(http4sMUnitClient.expect[String](GET(uri"/count")), "42")
}
}A request run with withHttpApp only redirects that single DSL request; http4sMUnitClient still
returns the client built from http4sMUnitClientResource.
Once the request has been passed to the test method, we can tag our tests before implementing them:
// Marks the test as failing (it will pass if the assertion fails)
test(GET(uri"hello")).fail { response => assertEquals(response.status.code, 200) }
// Marks a test as "flaky". Check MUnit docs to know more about this feature:
// https://scalameta.org/munit/docs/tests.html#tag-flaky-tests
test(GET(uri"hello")).flaky { response => assertEquals(response.status.code, 200) }
// Skips this test when running the suite
test(GET(uri"hello")).ignore { response => assertEquals(response.status.code, 200) }
// Runs only this test when running the suite
test(GET(uri"hello")).only { response => assertEquals(response.status.code, 200) }
// We can also use our own tags, just like with MUnit `test`
val IntegrationTest = new munit.Tag("integration-test")
test(GET(uri"hello")).tag(IntegrationTest) { response => assertEquals(response.status.code, 200) }http4s-munit includes a small feature that allows you to "stress-test" a service. Once the request has been passed to the test method, we can call several methods to enable test repetition and parallelization:
test(GET(uri"hello"))
.repeat(50)
.parallel(10) { response =>
assertEquals(response.status.code, 200)
}On the other hand, if you do not want to have to call these methods for each test, you also have the possibility to enable repetition and parallelization using system properties or environment variables:
-
Using environment variables:
export HTTP4S_MUNIT_REPETITIONS=50 export HTTP4S_MUNIT_MAX_PARALLEL=10 sbt test
-
Using system properties:
sbt -Dhttp4s.munit.repetitions=50 -Dhttp4s.munit.max.parallel=10 test
Also, when multiple errors occured while running repeated tests, you can control wheter http4s-munit should output all failures or not using:
# Using environment variable
export HTTP4S_SHOW_ALL_STACK_TRACES=true
# Using system property
sbt -Dhttp4s.munit.showAllStackTraces=true testFinally, if you want to disable repetitions for a specific test when using environment variables or system properties, you can use doNotRepeat:
test(GET(uri"hello")).doNotRepeat { response =>
assertEquals(response.status.code, 200)
}Sometimes one test needs some pre-condition in order to be executed (e.g., in order to test the deletion of a user, you need to create it first). In such cases, once the request has been passed to the test method, we can call andThen to provide nested requests from the response of the previous one:
test(GET(uri"posts" +? ("number" -> 10)))
.alias("look for the 10th post")
.andThen("delete it")(_.as[String].map { id =>
DELETE(uri"posts" / id)
}) { response =>
assertEquals(response.status.code, 204)
}The generated test names can be customized by overriding http4sMUnitTestNameCreator. It allows altering the name of the generated tests.
Default implementation generates test names like:
// GET -> users/42
test(GET(uri"users" / "42"))
// GET -> users (all users)
test(GET(uri"users")).alias("all users")
// GET -> users - executed 10 times with 2 in parallel
test(GET(uri"users")).repeat(10).parallel(2)
// GET -> posts?number=10 (look for the 10th post and delete it)
test(GET(uri"posts" +? ("number" -> 10)))
.alias("look for the 10th post")
.andThen("delete it")(_.as[String].map { id => DELETE(uri"posts" / id) })
// say hello (a deferred request is named after its alias)
test(IO(GET(uri"hello"))).alias("say hello")http4s-munit always includes the responses body in a failed assertion's message.
For example, when running the following suite...
import cats.effect.IO
import org.http4s._
class MySuite extends munit.Http4sSuite {
override def http4sMUnitClientResource =
HttpRoutes.of[IO] { case _ => Ok("""{"id": 1, "name": "Jose"}""") }.orFail.asClient
test(GET(uri"users"))(response => assertEquals(response.status.code, 204))
}...it will fail with this message:
X MySuite.GET -> users 0.042s munit.ComparisonFailException: MySuite.scala:12
12: test(GET(uri"users"))(response => assertEquals(response.status.code, 204))
values are not the same
=> Obtained
200
=> Diff (- obtained, + expected)
-200
+204
Response body was:
{
"id": 1,
"name": "Jose"
}
The body will be prettified using http4sMUnitBodyPrettifier, which, by default, will try to parse it as JSON and apply a code highlight if munitAnsiColors is true. If you want a different output or disabling body-prettifying just override this method.
Apart from the response body clues introduced in the previous section, http4s-munit also provides a simple way to transform a response into clues: the response.clues extension method.
The output of this extension method can be tweaked by using the http4sMUnitResponseClueCreator.
For example, this can be used on container suites to filter logs relevant to the current request (if your logs are JSON objects containing the request id):
import cats.effect.IO
import cats.effect.Resource
import cats.syntax.all._
import com.dimafeng.testcontainers.GenericContainer
import io.circe.Json
import org.http4s._
import org.http4s.circe._
import org.http4s.ember.client.EmberClientBuilder
import org.typelevel.ci._
class TestContainersSuite extends munit.Http4sSuite {
override def http4sMUnitClientResource =
Resource.fromAutoCloseable(IO(container.start()).as(container)) >>
EmberClientBuilder.default[IO].build.map(_.withBaseUri(localhost.withPort(container.mappedPort(80))))
override def http4sMUnitResponseClueCreator(response: Response[IO]) = {
val logs = response.headers
.get(ci"x-request-id")
.map(_.head.value)
.map(id => container.logs.split("\n").filter(_.contains(id)).mkString("\n"))
.getOrElse(container.logs)
clues(response, logs)
}
lazy val container = GenericContainer(dockerImage = "mendhak/http-https-echo", exposedPorts = List(80))
test(GET(uri"ping")) { response =>
assertEquals(response.status.code, 200, response.clues)
assertIOBoolean(response.as[Json].map(_.isObject), response.clues)
}
}