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:

src/hono-base.ts
 * `.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:

src/hono-base.ts
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 reading c.env.MY_KV can be tested by passing { MY_KV: fakeKv } as the third argument. No miniflare needed for logic tests.
  • executionCtx -- a stub { waitUntil, passThroughOnException } if handlers use c.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:

src/helper/testing/index.ts
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 as Hono erases 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.request for 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.

Sources: src/hono-base.ts, src/helper/testing/index.ts · last synced 2026-08-10 · 26de731 · version 4.13.1