Guide

Testing

Test handler functions with Request.fake and KHandler.test or ZHandler.test, without h3, Nitro or a server.

A handler is a plain function from Request to a program. Test it like any other function: build a fake request, run it with KHandler.test or ZHandler.test, and check the head and the body. You need no h3, no Nitro and no server.

Status. Request.fake is new in 0.2.0-alpha.1. KHandler.test, ZHandler.test, TestResponse and Fetch.fake are new in 0.3.0-alpha.1; they replace the manual provisioning with Var.runTuple and ZIO.stateful that this guide showed for 0.2.0-alpha.1.

Keep handlers as values

Define the handler function as a value, and pass it to AppHandler.define (or KHandler.define, ZHandler.define) separately. The demo's ZIO handlers do this in NotesHandler.scala:

object NotesHandler:
    val create: Request => ZIO[HandlerEnv & Notes, Throwable, Note] = request =>
      for
        input <- request.body.json[CreateNote]
        note  <- ZIO.serviceWithZIO[Notes](_.create(input.text))
        _     <- Response.status(Status.Created)
      yield note

@JSExportTopLevel("default", "notes-post")
val createNote: EventHandler = AppHandler.define(NotesHandler.create)

Put services behind traits, so a test can provide an in-memory implementation.

Build a fake request

Request.fake takes named parameters, all optional:

Request.fake(
  method = "POST",
  path = "/api/notes",
  headers = Map("content-type" -> "application/json"),
  query = Map("page" -> List("2")),
  routerParams = Map("id" -> "7"),
  cookies = Map("session" -> "abc"),
  body = Some("""{"text":"hello"}"""),
)

development, debug, bodyLimit, requireJsonContentType and runtimeConfig are also available. Body reads follow the same rules as a real request: the content type, the limit, invalid JSON and schema validation behave the same. Like HandlerOptions, Request.fake requires a JSON content type by default, so give a fake request with a body that you read with json[A] a content-type header. Pass requireJsonContentType = false to test a handler that turns the option off.

A handler that reads configuration per request with RuntimeConfig.get(request) (Kyo) or ZRuntimeConfig.get(request) (ZIO) needs the config in the fake request, as JSON text:

val request = Request.fake(runtimeConfig = Some("""{"greeting":{"salutation":"Hi"}}"""))

Without runtimeConfig, those reads fail with ConfigError("h34s Nitro plugin is not installed"), as they do without the h34s module. Often it is simpler to read configuration in your AppHandler and pass values to handlers as services.

Kyo: KHandler.test

Call KHandler.test(handler)(request). It provides what KHandler.define provides for a request: a request scope, an empty response head, a transport for Response.sendStream and a server-side $fetch. It gives a TestResponse with the head that your program set and the body. This example is the "guide's example" test of HandlerTestSuite:

val handler: Request => Greeting < HandlerEffects = request =>
  for
    input <- request.body.json[Input]
    _     <- Response.status(Status.Created)
  yield Greeting(s"Hello, ${input.name}")

val request = Request.fake(method = "POST", headers = Map("content-type" -> "application/json"), body = Some("""{"name":"Scala"}"""))
val program = KHandler.test(handler)(request)
// program: TestResponse[Greeting] < (Async & Abort[Throwable])

import AllowUnsafe.embrace.danger
Fiber.Unsafe.init(Abort.run[Throwable](program)).toFuture().map { result =>
  val response = result.getOrThrow
  assertEquals(response.status, Some(Status.Created))
  assertEquals(response.body, TestResponse.Body.Value(Greeting("Hello, Scala")))
}

The last part runs the program with munit, as the h34s suites do. Use your own Kyo test setup if you have one.

Provide services inside the function, with Env.run, as you do for KHandler.define. fake is your in-memory Greetings:

val handler: Request => Greeting < (HandlerEffects & Env[Greetings]) = request =>
  Env.use[Greetings](greetings => Greeting(greetings.greet(request.query.getOrElse("name", "you"))))

val program = KHandler.test(request => Env.run(fake)(handler(request)))(Request.fake(query = Map("name" -> List("Ada"))))

A failure of your program stays a failure of program. Run it with Abort.run to check an error, for example Abort.run[HttpError](KHandler.test(handler)(request)).

ZIO: ZHandler.test

Call ZHandler.test(handler)(request). It provides the ZState[ResponseHead], a request Scope, the transport for Response.sendStream and a server-side $fetch, and gives a TestResponse. Your program's typed error stays in the error channel. This example is the "guide's example" test of HandlerTestSpec:

val handler: Request => ZIO[HandlerEnv, Throwable, Greeting] = request =>
  for
    input <- request.body.json[Input]
    _     <- Response.status(Status.Created)
  yield Greeting(s"Hello, ${input.name}")

val request = Request.fake(method = "POST", headers = Map("content-type" -> "application/json"), body = Some("""{"name":"Scala"}"""))
for response <- ZHandler.test(handler)(request)
yield assertTrue(
  response.status.contains(Status.Created),
  response.body == TestResponse.Body.Value(Greeting("Hello, Scala")),
)

Provide services inside the function, as you do for ZHandler.define. A rejected request fails with the same error that a client would see as its status:

val handler: Request => ZIO[HandlerEnv & Greetings, HttpError, Greeting] = request =>
  for
    name     <- ZIO.fromOption(request.query.get("name")).orElseFail(HttpError(400, "name is required"))
    greeting <- ZIO.serviceWithZIO[Greetings](_.greet(name))
  yield Greeting(greeting)

val fake = new Greetings:
    def greet(name: String): UIO[String] = ZIO.succeed(s"Hi, $name")
def run(request: Request) =
  ZHandler.test(request => handler(request).provideSomeEnvironment[HandlerEnv](_.add[Greetings](fake)))(request)

for
  greeted  <- run(Request.fake(query = Map("name" -> List("Ada"))))
  rejected <- run(Request.fake()).either
yield assertTrue(
  greeted.body == TestResponse.Body.Value(Greeting("Hi, Ada")),
  rejected.left.toOption.map(_.status).contains(400),
)

The demo's NotesHandlerSpec tests its real handlers this way, with an in-memory Notes.

Answer $fetch calls

KHandler.test and ZHandler.test use Fetch.unavailable by default: a handler that calls $fetch fails (Kyo) or dies (ZIO), and the message names Fetch.fake. Pass Fetch.fake as the second argument to answer calls from a map of URL to response. It records every request, and a URL without an entry answers 404.

Kyo, the Fetch.fake test of HandlerTestSuite:

val fetch = Fetch.fake(Map(
  "/api/greeting" -> FetchResponse.Plain(200, Map("content-type" -> "application/json"), """{"data":{"message":"hi"}}"""),
))
val handler: Request => String < HandlerEffects = _ =>
  for
    greeting <- KHandler.$fetch.get[GreetingData]("/api/greeting")
    missing  <- KHandler.$fetch.raw("/api/missing")
  yield s"${greeting.data.message} ${missing.status}"

val program = KHandler.test(handler)(Request.fake(), fetch)

ZIO, the Fetch.fake test of HandlerTestSpec. Fetch, FetchResponse and FetchError come from dev.yuri4n.exts.zioh3:

val fetch = Fetch.fake(Map(
  "/api/greeting" -> FetchResponse.Plain(200, Map("content-type" -> "application/json"), """{"data":{"message":"hi"}}"""),
))
val handler: Request => ZIO[HandlerEnv, FetchError, String] = _ =>
  for
    greeting <- ZHandler.$fetch.get[GreetingData]("/api/greeting")
    missing  <- ZHandler.$fetch.raw("/api/missing")
  yield s"${greeting.data.message} ${missing.status}"

for response <- ZHandler.test(handler)(Request.fake(), fetch)
yield assertTrue(
  response.body == TestResponse.Body.Value("hi 404"),
  fetch.requests.map(_.url) == List("/api/greeting", "/api/missing"),
)

In both, the body is TestResponse.Body.Value("hi 404"), and fetch.requests.map(_.url) is List("/api/greeting", "/api/missing"). A fake streamed response takes the stream of its runtime: FetchResponse.Streamed(200, headers, Stream.init(Seq("a", "b"))) in Kyo, FetchResponse.Streamed(200, headers, ZStream("a", "b")) in ZIO.

Fetch.fake gives each response as you wrote it. It forwards no headers, adds no base URL, and does not apply the Plain or Streamed rule of the real $fetch: the response in your map is what the handler gets. A fake Streamed body is your stream as you wrote it, too. The cancel of a body that the handler does not read to its end, and the wait for the called route's cleanup, belong to the real $fetch; NitroLocalFetchSuite (Kyo) and NitroLocalFetchSpec (ZIO) test them. See in-process calls for the rules of the real $fetch.

Test a streaming handler

The test helpers collect what your handler sends with Response.sendStream. The body is then TestResponse.Body.Streamed(elements), the strings in order. The stream's resources are released when it ends, before the request scope closes, as in a real request.

Kyo:

val handler: Request => Sent < HandlerEffects = _ =>
  Response.header("content-type", "application/x-ndjson").andThen(Response.sendStream(Stream.init(Seq("1\n", "2\n"))))

val program = KHandler.test(handler)(Request.fake())
// the body of the result: TestResponse.Body.Streamed(List("1\n", "2\n"))

ZIO:

val handler: Request => ZIO[HandlerEnv, Throwable, Sent] = _ =>
  Response.header("content-type", "application/x-ndjson") *> Response.sendStream(ZStream("1\n", "2\n"))

for response <- ZHandler.test(handler)(Request.fake())
yield assertTrue(response.body == TestResponse.Body.Streamed(List("1\n", "2\n")))

A stream that fails makes the test program fail with the stream's error, as sendStream does in a real request.

Test a handler that sends events

Response.sendEvents is sendStream over frames, so the body is TestResponse.Body.Streamed with one frame per event, and the head has the event-stream headers that sendEvents sets. In these snippets, Greeting is a model with a message field. Kyo:

val handler: Request => Sent < HandlerEffects = _ =>
  Response.sendEvents(Stream.init(Seq(ServerEvent.json(Greeting("hi"), event = Some("greeting")), ServerEvent("bye"))))

val program = KHandler.test(handler)(Request.fake())
// the body of the result: TestResponse.Body.Streamed(List("event: greeting\ndata: {\"message\":\"hi\"}\n\n", "data: bye\n\n"))

ZIO:

val handler: Request => ZIO[HandlerEnv, Throwable, Sent] = _ =>
  Response.sendEvents(ZStream(ServerEvent.json(Greeting("hi"), event = Some("greeting")), ServerEvent("bye")))

for response <- ZHandler.test(handler)(Request.fake())
yield assertTrue(
  response.body == TestResponse.Body.Streamed(List("event: greeting\ndata: {\"message\":\"hi\"}\n\n", "data: bye\n\n")),
  response.header("content-type").contains("text/event-stream"),
  response.header("cache-control").contains("private, no-cache, no-store, no-transform, must-revalidate, max-age=0"),
  response.header("x-accel-buffering").contains("no"),
  response.header("connection").contains("keep-alive"),
)

To test the caller side, give Fetch.fake a response with the content type text/event-stream, Plain or Streamed: $fetch.events decodes its text. The test "$fetch.events decodes a fake event stream, Plain or Streamed, by its content type and sends accept: text/event-stream" (HandlerTestSuite, HandlerTestSpec) does this.

Read a TestResponse

MemberValue
headThe ResponseHead that your program set; after sendStream, the one at the call
statushead.status; None when your program set none
header(name)The header's values after your sets and appends, joined with ", "; names ignore case
headerValues(name)The same values as a list
cookiesThe cookie entries that your program set
bodyTestResponse.Body.Value(value), or TestResponse.Body.Streamed(elements)

head is what your program set. For a handler that sends a stream, it is the head at the sendStream call, as on the wire: a change after the call does not reach the client, so it is not in head either. Handler defaults (HandlerOptions.defaults), the default content type and the envelope are added by h34s when it writes a real response, so they are not in it. Check them end to end, as described below.

Test the wire contract

Your clients depend on the JSON shapes, the statuses and the error entries. Pin them in tests:

  • Model shapes. Convert a value and compare the JSON text with what your clients expect: note.toJson (from NativeConverter). For a converter that describes its TypeScript type, also check converter.wireType.describes(js.JSON.parse(note.toJson)); see routes and typed $fetch. A schema type gives the shapes listed in schemas.
  • Error entries. Assert the exact InvalidInput entries, as above. They are your 422 contract.
  • Statuses and headers. Assert status, header and cookies of the TestResponse. Remember that on failure the core keeps only OnFailure.Keep entries.
  • The envelope. {"data": ...}, {"errors": [...]} and 204 come from the core and are covered by h34s's own tests. Check them end to end with an HTTP request against a running server, for example in a smoke script.

Test configuration decoding

Decode JSON text with your config's converter, without Nitro and without scala.scalajs.js. The demo's AppConfigSpec:

val runtimeConfig =
  "{\"database\":{\"host\":\"127.0.0.1\",\"port\":5432,\"user\":\"h34s\",\"password\":\"hunter2\",\"name\":\"h34s\"}}"
val config = summon[NativeConverter[AppConfig]].fromJson(runtimeConfig)
assertTrue(config.database.password.reveal == "hunter2", !config.toString.contains("hunter2"))

Test shared resources

Without the Nitro plugin, Shared and ZShared register nothing. Call close at the end of the test to release the resource.

What h34s tests for you

The h34s suites pin the behavior your handlers rely on. For 0.2.0-alpha.1, on macOS x86_64 with Node 24.19.0: h3Ext 36 tests, kyoH3Ext 79, zioH3Ext 81, nativeConverterKyoSchemaExt 25, nativeConverterZIOSchemaExt 20, kyoNuxtHubExt 15, zioNuxtHubExt 14, the demo's nuxtsvr 6, and the Node tests of the npm package. After the sendStream change, on a Linux x86_64 cloud VM with Node 24.22.0: h3Ext 49, kyoH3Ext 122, zioH3Ext 104, nativeConverterKyoSchemaExt 29, nativeConverterZIOSchemaExt 22, kyoNuxtHubExt 15, zioNuxtHubExt 14 and nuxtsvr 7. After the server-side $fetch of both runtimes, on the same kind of VM: h3Ext 55, kyoH3Ext 131, zioH3Ext 119, nativeConverterKyoSchemaExt 30, nativeConverterZIOSchemaExt 24, kyoNuxtHubExt 15, zioNuxtHubExt 14 and nuxtsvr 7. They cover request snapshots and body limits, the wire format and envelopes, the failure rule, safe and opaque errors, diagnostics, request finalizers, client disconnects and uninterruptible steps, streaming, the server-side $fetch, the test helpers, Shared/ZShared, runtime config, storage and the schema bridges, through real h3 1.15.4 events with mocked Node responses, and (for streams and $fetch) a copy of Nitro 2.13.1's in-process local fetch. See compatibility for the evidence.