Because a Hono app is just a fetch function over Web Standard objects, testing needs no server, no port, and no supertest-style harness. The framework ships two entry points for tests: app.request() on every app instance, and the typed testClient from hono/testing.
app.request: the built-in harness
request() is defined in src/hono-base.ts and its own JSDoc is the canonical recipe:
* `.request()` is a useful method for testing.
* You can pass a URL or pathname to send a GET request.
* app will return a Response object.
* ```ts
* test('GET /hello is ok', async () => {
* const res = await app.request('/hello')
* expect(res.status).toBe(200)
* })
* ```
Under the hood it wraps your input in a real Request (prefixing http://localhost for bare paths) and calls app.fetch:
request = (input, requestInit?, Env?, executionCtx?) => {
if (input instanceof Request) {
return this.fetch(requestInit ? new Request(input, requestInit) : input, Env, executionCtx)
}
input = input.toString()
return this.fetch(
new Request(
/^https?:\/\//.test(input) ? input : `http://localhost${mergePath('/', input)}`,
requestInit
),
Env,
executionCtx
)
}
That signature gives you three levers:
requestInit-- method, headers, body:app.request('/entry', { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } }).Env-- fake bindings. A Workers app readingc.env.MY_KVcan be tested by passing{ MY_KV: fakeKv }as the third argument. No miniflare needed for logic tests.executionCtx-- a stub{ waitUntil, passThroughOnException }if handlers usec.executionCtx.
Hono's own core test suite is 3,800 lines of exactly this pattern (src/hono.test.ts), which makes it a good place to copy assertions from -- including edge cases like app.request('http://localhost/hello') with a full URL when the host matters.
testClient: tests that break when routes change
hono/testing wraps the RPC client around app.request, so tests get the same typed surface as production hc callers. The implementation is small enough to quote whole:
export const testClient = <T extends Hono<any, Schema, string>>(
app: T,
Env?: ExtractEnv<T>['Bindings'] | {},
executionCtx?: ExecutionContext,
options?: Omit<ClientRequestOptions, 'fetch'>
): UnionToIntersection<Client<T, 'http://localhost'>> => {
const customFetch = (input: RequestInfo | URL, init?: RequestInit) => {
return app.request(input, init, Env, executionCtx)
}
return hc<typeof app, 'http://localhost'>('http://localhost', { ...options, fetch: customFetch })
}
Usage: const res = await testClient(app).search.$get({ query: { q: 'hono' } }). Two practical caveats that follow directly from the types:
- The type parameter must be inferred from a concrete app with routes chained on it (
const app = new Hono().get('/search', ...)) -- annotating the variable asHonoerases the schema and the client degrades to untyped. - Paths not registered with the chaining style will not appear on the client; fall back to
app.requestfor those.
Testing against real runtimes
Unit-level app.request tests run in whatever runs Vitest. When behavior genuinely depends on the runtime (WebSockets, serveStatic, bindings semantics), follow the repo's own pattern of runtime projects -- vitest --run --project workerd boots real workerd through wrangler. See The test matrix for how those suites are wired, and copy runtime-tests/workerd/ as a template.