Testing
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
| Member | Value |
|---|---|
head | The ResponseHead that your program set; after sendStream, the one at the call |
status | head.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 |
cookies | The cookie entries that your program set |
body | TestResponse.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(fromNativeConverter). For a converter that describes its TypeScript type, also checkconverter.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
InvalidInputentries, as above. They are your 422 contract. - Statuses and headers. Assert
status,headerandcookiesof theTestResponse. Remember that on failure the core keeps onlyOnFailure.Keepentries. - 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.
Streaming and cancellation
Send a Kyo Stream or a ZStream with Response.sendStream; h34s writes the head at once, keeps the request scope open and waits for cleanup when the client goes away.
Nitro storage and NuxtHub KV
Open a Nitro storage mount from Scala with KHubKv.mount or ZHubKv.mount, with no native objects.