MUnit with http4s
Write integration tests to prevent regressions in your code. MUnit is a Scala testing library. Http4s is a web framework for Scala. This post demonstrates how to write integration tests for http4s web services using MUnit.
The app
Consider the following http4s web service:
class AppService extends Http4sDsl[IO]:
val routes = HttpRoutes.of[IO]:
case GET -> Root / "ping" => Ok("pong")
.orNotFound
object AppServer extends IOApp:
type Routes = Kleisli[IO, Request[IO], Response[IO]]
val app: Routes = AppService().routes
val server = EmberServerBuilder
.default[IO]
.withHost(host"0.0.0.0")
.withPort(port"9000")
.withHttpApp(app)
.build
override def run(args: List[String]): IO[ExitCode] =
server.use(_ => IO.never).as(ExitCode.Success)
One app per test case
We can test this minimal app in a MUnit test suite as follows:
class AppServiceTests extends FunSuite:
val app = FunFixture[AppServer.Routes](
setup = test => AppServer.app,
teardown = appl =>
// Always gets called, even if test failed.
()
)
app.test("ping app"): routes =>
val response = routes.run(Request(uri = uri"/ping")).unsafeRunSync()
assertEquals(response.status, Status.Ok)
One app per suite
To share the app instance between all tests in the suite, define this trait:
trait Http4sSuite:
self: Suite =>
val httpApp: Fixture[AppServer.Routes] = new Fixture[AppServer.Routes]("app"):
private var service: Option[AppServer.Routes] = None
override def apply(): AppServer.Routes = service.get
override def beforeAll(): Unit =
service = Option(AppServer.app)
override def munitFixtures: Seq[Fixture[?]] = Seq(httpApp)
Use it in your test suite:
class OneAppPerSuite extends FunSuite with Http4sSuite:
test("run app"):
val routes = httpApp()
val response = routes.run(Request(uri = uri"/ping")).unsafeRunSync()
assertEquals(response.status, Status.Ok)
Adding a database
It is common to interface with a database in a backend environment, such that a database connection pool is opened on app startup and closed on app shutdown. In http4s you model such a pattern using a cats.effect.Resource. Setting up http4s and test suites gets a bit more involved, so in the following we will:
- Modify the initial example http4s service so that it supports a database resource.
- Define a MUnit suite that manages a database instance for testing purposes.
- Define another suite that provides the test database instance to our http4s service.
First, our app that uses a database now looks like this:
case class DatabaseConf(url: String, user: String, pass: String)
class DatabaseService(database: HikariTransactor[IO]) extends Http4sDsl[IO]:
val routes = HttpRoutes
.of[IO]:
case GET -> Root / "ping" => Ok("pong")
.orNotFound
object DatabaseApp extends IOApp:
def appResource(conf: DatabaseConf): Resource[IO, DatabaseService] =
for
ce <- ExecutionContexts.fixedThreadPool[IO](32)
transactor <- HikariTransactor.newHikariTransactor[IO](
"com.mysql.jdbc.Driver",
conf.url,
conf.user,
conf.pass,
ce
)
yield DatabaseService(transactor)
def buildServer(conf: DatabaseConf) =
for
app <- appResource(conf)
server <- EmberServerBuilder
.default[IO]
.withHost(host"0.0.0.0")
.withPort(port"9000")
.withHttpApp(app.routes)
.withShutdownTimeout(1.millis)
.build
yield server
override def run(args: List[String]): IO[ExitCode] =
buildServer(readConf).use(_ => IO.never).as(ExitCode.Success)
def readConf: DatabaseConf = ???
(You might read the database configuration from environment variables in a production setting, but I leave that up to you.)
To obtain a database for testing purposes, we use testcontainers-scala. The following MUnit suite utilizes Docker to start and stop a database once for all tests in your suite:
trait DatabaseSuite:
self: Suite =>
val db: Fixture[DatabaseConf] = new Fixture[DatabaseConf]("database"):
var container: Option[MySQLContainer] = None
var conf: Option[DatabaseConf] = None
def apply(): DatabaseConf = conf.get
override def beforeAll(): Unit =
val image = DockerImageName.parse("mysql:5.7.29")
val cont = MySQLContainer(mysqlImageVersion = image)
cont.start()
container = Option(cont)
val databaseConf = DatabaseConf(
s"${cont.jdbcUrl}?useSSL=false",
cont.username,
cont.password
)
conf = Option(databaseConf)
override def afterAll(): Unit =
container.foreach(_.stop())
override def munitFixtures: Seq[Fixture[?]] = Seq(db)
Now we create a master test suite that starts a database and launches an http4s service that uses the database:
trait DatabaseAppSuite extends DatabaseSuite:
self: Suite =>
val dbApp: Fixture[DatabaseService] = new Fixture[DatabaseService]("db-app"):
private var service: Option[DatabaseService] = None
val promise = Promise[IO[Unit]]()
override def apply(): DatabaseService = service.get
override def beforeAll(): Unit =
val resource = DatabaseApp.appResource(db())
val resourceEffect = resource.allocated[DatabaseService]
val setupEffect =
resourceEffect.map:
case (t, release) =>
promise.success(release)
t
.flatTap(t => IO.pure(()))
service = Option(setupEffect.unsafeRunSync())
override def afterAll(): Unit =
IO.fromFuture(IO(promise.future)).flatten.unsafeRunSync()
override def munitFixtures: Seq[Fixture[?]] = Seq(db, dbApp)
Note that the fixtures are initialized in the order listed in munitFixtures
. You want your database to be available
when your application starts, therefore the database fixture must precede the application fixture. Finally, let's write
the tests:
class DatabaseAppTests extends FunSuite with DatabaseAppSuite:
test("test app"):
val service = dbApp()
val request = Request[IO](uri = uri"/ping")
val response = service.routes.run(request).unsafeRunSync()
assertEquals(response.status, Status.Ok)
The code for this post is available on GitHub.