http4s Testing: httpRoutes, Middleware, and Integration Test Patterns
http4s is a purely functional HTTP library for Scala built on cats-effect. Its route handlers are values — HttpRoutes[F] is just a function from Request[F] to OptionT[F, Response[F]]. This makes testing routes trivial: construct a request, run it through the routes, inspect the response — all without starting a real server.
This guide covers route testing, middleware verification, authentication testing, and integration patterns for http4s applications.
Setup
// build.sbt
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-dsl" % "0.23.26",
"org.http4s" %% "http4s-ember-server" % "0.23.26",
"org.http4s" %% "http4s-circe" % "0.23.26",
"org.typelevel" %% "munit-cats-effect" % "2.0.0" % Test,
"org.http4s" %% "http4s-ember-client" % "0.23.26" % Test
)Testing Routes Without a Server
http4s routes are pure functions — no server required:
import cats.effect.IO
import munit.CatsEffectSuite
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.circe._
import io.circe.generic.auto._
class UserRoutesSpec extends CatsEffectSuite {
// Create routes with a test service
val userService = new InMemoryUserService[IO]()
val routes = UserRoutes.make[IO](userService).orNotFound
test("GET /users/:id returns user JSON") {
val request = Request[IO](Method.GET, uri"/users/1")
for {
response <- routes.run(request)
body <- response.as[User]
} yield {
assertEquals(response.status, Status.Ok)
assertEquals(body.email, "alice@example.com")
}
}
test("GET /users/:id returns 404 for unknown user") {
val request = Request[IO](Method.GET, uri"/users/99999")
routes.run(request).map { response =>
assertEquals(response.status, Status.NotFound)
}
}
test("POST /users creates a new user") {
val payload = UserCreate("bob@example.com", "password123")
val request = Request[IO](Method.POST, uri"/users")
.withEntity(payload.asJson)
for {
response <- routes.run(request)
body <- response.as[User]
} yield {
assertEquals(response.status, Status.Created)
assertEquals(body.email, "bob@example.com")
assert(body.id > 0)
}
}
}orNotFound converts HttpRoutes[F] (which returns OptionT) to HttpApp[F] (which always returns a response — 404 for unmatched routes).
Request Construction
// GET with query params
Request[IO](
Method.GET,
Uri.unsafeFromString("/search?q=scala&page=2")
)
// Using uri interpolator
import org.http4s.implicits._
val uri = uri"/users" / "42" +? ("include", "posts")
// POST with JSON body
import org.http4s.circe._
import io.circe.syntax._
Request[IO](Method.POST, uri"/posts")
.withEntity(CreatePost("title", "body").asJson)
// With headers
Request[IO](Method.GET, uri"/protected")
.withHeaders(
Header.Raw(ci"Authorization", s"Bearer $token"),
Header.Raw(ci"Accept", "application/json")
)
// With cookies
Request[IO](Method.GET, uri"/dashboard")
.addCookie("session", sessionToken)Testing JSON Responses
import io.circe.Json
import org.http4s.circe._
test("returns user list as JSON array") {
for {
response <- routes.run(Request[IO](Method.GET, uri"/users"))
json <- response.as[Json]
} yield {
assertEquals(response.status, Status.Ok)
assert(json.isArray)
assertEquals(json.asArray.get.size, 3)
}
}
test("includes pagination metadata") {
for {
response <- routes.run(Request[IO](Method.GET, uri"/posts?page=2&per_page=10"))
json <- response.as[Json]
} yield {
val meta = json.hcursor.downField("meta")
assertEquals(meta.get[Int]("page").toOption, Some(2))
assertEquals(meta.get[Int]("per_page").toOption, Some(10))
}
}Testing Middleware
Middleware wraps HttpRoutes[F] with cross-cutting logic. Test it independently:
Authentication Middleware
class AuthMiddlewareSpec extends CatsEffectSuite {
val protectedRoutes = HttpRoutes.of[IO] {
case GET -> Root / "protected" => Ok("secret data")
}
val authMiddleware = AuthMiddleware.make[IO](TokenValidator.instance)
val app = authMiddleware(protectedRoutes).orNotFound
test("rejects request without Authorization header") {
val request = Request[IO](Method.GET, uri"/protected")
app.run(request).map { response =>
assertEquals(response.status, Status.Unauthorized)
}
}
test("rejects request with invalid token") {
val request = Request[IO](Method.GET, uri"/protected")
.withHeaders(Header.Raw(ci"Authorization", "Bearer invalid-token"))
app.run(request).map { response =>
assertEquals(response.status, Status.Unauthorized)
}
}
test("allows request with valid token") {
val token = JwtUtils.generateToken(userId = 1)
val request = Request[IO](Method.GET, uri"/protected")
.withHeaders(Header.Raw(ci"Authorization", s"Bearer $token"))
for {
response <- app.run(request)
body <- response.bodyText.compile.string
} yield {
assertEquals(response.status, Status.Ok)
assertEquals(body, "secret data")
}
}
}CORS Middleware
test("adds CORS headers to response") {
val corsMiddleware = CORS.policy
.withAllowOriginAll
.withAllowMethodsAll
.httpApp(innerApp)
val request = Request[IO](Method.GET, uri"/api/data")
.withHeaders(Header.Raw(ci"Origin", "https://frontend.example.com"))
corsMiddleware.run(request).map { response =>
val origin = response.headers.get(ci"Access-Control-Allow-Origin")
assert(origin.isDefined)
}
}Rate Limiting Middleware
test("rejects requests exceeding rate limit") {
val limiter = RateLimitMiddleware.make[IO](maxRequests = 5, window = 1.minute)
val limited = limiter(innerRoutes).orNotFound
// First 5 requests succeed
List.fill(5)(Request[IO](Method.GET, uri"/api")).traverse { req =>
limited.run(req).map(resp => assertEquals(resp.status, Status.Ok))
} *>
// 6th request is rejected
limited.run(Request[IO](Method.GET, uri"/api")).map { response =>
assertEquals(response.status, Status.TooManyRequests)
}
}Testing with Injectable Dependencies
Use traits for test doubles:
trait UserRepository[F[_]] {
def find(id: Long): F[Option[User]]
def save(user: User): F[User]
def findByEmail(email: String): F[Option[User]]
}
// Test implementation
class InMemoryUserRepository[F[_]: Sync] extends UserRepository[F] {
private val store = scala.collection.concurrent.TrieMap.empty[Long, User]
private val idGen = new java.util.concurrent.atomic.AtomicLong(0)
def find(id: Long): F[Option[User]] =
Sync[F].delay(store.get(id))
def save(user: User): F[User] = Sync[F].delay {
val id = idGen.incrementAndGet()
val saved = user.copy(id = id)
store.put(id, saved)
saved
}
def findByEmail(email: String): F[Option[User]] =
Sync[F].delay(store.values.find(_.email == email))
}Use in tests:
class UserRoutesSpec extends CatsEffectSuite {
def makeRoutes(): IO[(UserRepository[IO], HttpApp[IO])] = IO {
val repo = new InMemoryUserRepository[IO]()
val routes = UserRoutes.make[IO](repo).orNotFound
(repo, routes)
}
test("GET /users/:id") {
for {
(repo, app) <- makeRoutes()
alice <- repo.save(User(0, "alice@example.com"))
response <- app.run(Request[IO](Method.GET, uri"/users" / alice.id.toString))
body <- response.as[User]
} yield {
assertEquals(response.status, Status.Ok)
assertEquals(body.email, "alice@example.com")
}
}
}Integration Testing with Real Server
For integration tests, start a real server with a random port:
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.ember.client.EmberClientBuilder
import com.comcast.ip4s._
class IntegrationSpec extends CatsEffectSuite {
val serverFixture = ResourceFixture(
for {
repo <- Resource.eval(IO.ref(Map.empty[Long, User]))
routes = UserRoutes.make[IO](new RefUserRepository(repo))
server <- EmberServerBuilder.default[IO]
.withHost(ipv4"127.0.0.1")
.withPort(port"0") // random port
.withHttpApp(routes.orNotFound)
.build
client <- EmberClientBuilder.default[IO].build
} yield (server, client)
)
serverFixture.test("POST /users and GET /users/:id round-trip") { case (server, client) =>
val baseUri = Uri.unsafeFromString(s"http://127.0.0.1:${server.address.getPort}")
for {
createResp <- client.run(
Request[IO](Method.POST, baseUri / "users")
.withEntity(UserCreate("alice@example.com", "pass").asJson)
).use(_.as[User])
getResp <- client.run(
Request[IO](Method.GET, baseUri / "users" / createResp.id.toString)
).use(_.as[User])
} yield {
assertEquals(createResp.email, "alice@example.com")
assertEquals(getResp, createResp)
}
}
}Response Header Testing
test("returns content-type application/json") {
routes.run(Request[IO](Method.GET, uri"/users")).map { response =>
val contentType = response.contentType
assert(contentType.exists(_.mediaType == MediaType.application.json))
}
}
test("sets cache-control header") {
routes.run(Request[IO](Method.GET, uri"/static/data")).map { response =>
val cacheControl = response.headers.get(ci"Cache-Control")
assert(cacheControl.exists(_.head.value.contains("max-age")))
}
}Continuous Production Monitoring
http4s route tests run fast and in-memory, but they don't catch deployment issues, network configuration errors, or third-party service failures. HelpMeTest monitors your live http4s endpoints continuously — verifying response status codes, response times, and payload structure 24/7.
Summary
http4s routes are values — test them as values. Construct Request[IO], run through HttpApp[IO], assert on Response[IO]. No server, no network, no test containers required for unit and component testing.
Test middleware independently by wrapping simple inner routes. Use in-memory repository implementations for isolation. Add integration tests with EmberServerBuilder and a random port for full stack verification. The cats-effect ResourceFixture handles server lifecycle automatically.