http4s Testing: httpRoutes, Middleware, and Integration Test Patterns

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.

Read more