Skip to main content
doc0blade47/honodocumentation home
Sign in

Testing and Dev Tools

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Hono provides dedicated testing and development utilities designed to streamline application verification and inspection without requiring a running network server. These tools enable developers to simulate client requests, inspect registered routing hierarchies, mock environment bindings, and profile execution latency.

Sources: src/helper/testing/index.ts:1-26, src/helper/dev/index.ts:1-74, src/middleware/timing/timing.ts:1-126

Unit testing with testClient

Simulation Architecture

The testClient helper constructs an end-to-end type-safe RPC client via hc that routes requests directly through an application instance without spinning up an actual network server. It extracts environment bindings from the provided Hono application type and overrides the underlying fetch implementation to execute app.request directly.

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 })

Sources: src/helper/testing/index.ts:16-26

When an invocation occurs on the returned Client proxy, request parameters, JSON payloads, query strings, headers, and cookies are serialized into standard Web API Request objects. These inputs are subsequently dispatched to the application instance bound to http://localhost.

fetch = async (
  args?: ValidationTargets<FormValue> & {
    param?: Record<string, string>
  },
  opt?: ClientRequestOptions
) => {
  if (args) {
    if (args.query) {
      this.queryParams = this.buildSearchParams(args.query)
    }
 
    if (args.form) {
      const form = new FormData()
      for (const [k, v] of Object.entries(args.form)) {
        if (v === undefined) {
          continue
        }
        if (Array.isArray(v)) {
          for (const v2 of v) {
            form.append(k, v2)
          }
        } else {
          form.append(k, v)
        }
      }
      this.rBody = form
    }
 
    if (args.json) {
      this.rBody = JSON.stringify(args.json)
      this.cType = 'application/json'
    }
 
    if (args.param) {
      this.pathParams = args.param
    }
  }
 
  let methodUpperCase = this.method.toUpperCase()
 
  const headerValues: Record<string, string> = {
    ...args?.header,
    ...(typeof opt?.headers === 'function' ? await opt.headers() : opt?.headers),
  }
 
  if (args?.cookie) {
    const cookies: string[] = []
    for (const [key, value] of Object.entries(args.cookie)) {
      cookies.push(serialize(key, value, { path: '/' }))
    }
    headerValues['Cookie'] = cookies.join(',')
  }
 
  if (this.cType) {
    headerValues['Content-Type'] = this.cType
  }
 
  const headers = new Headers(headerValues ?? undefined)
  let url = this.url
 
  url = removeIndexString(url)
  url = replaceUrlParam(url, this.pathParams)
 
  if (this.queryParams) {
    url = url + '?' + this.queryParams.toString()
  }
  methodUpperCase = this.method.toUpperCase()
  const setBody = !(methodUpperCase === 'GET' || methodUpperCase === 'HEAD')
 
  // Pass URL string to 1st arg for testing with MSW and node-fetch
  return (opt?.fetch || fetch)(url, {
    body: setBody ? this.rBody : undefined,
    method: methodUpperCase,
    headers: headers,
    ...opt?.init,
  })
}

Sources: src/client/client.ts:53-129

Note

The testClient helper defaults to the base URL http://localhost, leveraging UnionToIntersection to flatten the generated Client type signature across all matching router definitions.

Sources: src/helper/testing/index.ts:16-21

Parameter Signature

The testClient function accepts four parameters to configure the simulated environment, execution context, and client options before dispatching requests.

ParameterTypeDefaultPurpose
appT extends Hono<any, Schema, string>requiredThe Hono application instance to test.
EnvExtractEnv<T>['Bindings'] | {}undefinedEnvironment bindings injected into the application request.
executionCtxExecutionContextundefinedExecution context passed to app.request.
optionsOmit<ClientRequestOptions, 'fetch'>undefinedAdditional client request configuration options excluding fetch.

Tip

Pass custom Bindings directly as the second argument to testClient to mock database connections, environment variables, or KV namespaces during unit testing.

Sources: src/helper/testing/index.ts:16-24

Route inspection and formatting

Application route analysis

The development helper module exposes utilities to inspect registered application routes and format them into readable output strings for terminal logging. The central engine for this capability is inspectRoutes, which iterates over hono.routes stored within the Application Core instance and normalizes each route entry into a structured RouteData object.

export const inspectRoutes = <E extends Env>(hono: Hono<E>): RouteData[] => {
  return hono.routes.map(({ path, method, handler }: RouterRoute) => {
    const targetHandler = findTargetHandler(handler)
    return {
      path,
      method,
      name: handlerName(targetHandler),
      isMiddleware: isMiddleware(targetHandler),
    }
  })
}

Sources: src/helper/dev/index.ts:27-37

During inspection, inspectRoutes calls findTargetHandler on the raw route handler to locate the underlying executable function. It then determines route metadata, returning an array of objects containing the endpoint path, HTTP method, resolved handler name, and a boolean flag indicating whether the handler acts as middleware.

interface RouteData {
  path: string
  method: string
  name: string
  isMiddleware: boolean
}
 
const handlerName = (handler: Function): string => {
  return handler.name || (isMiddleware(handler) ? '[middleware]' : '[handler]')
}

Sources: src/helper/dev/index.ts:16-25

Note

The getRouterName helper executes an initial app.router.match('GET', '/') probe to trigger router initialization and extract the underlying router implementation name from app.router.name.

Sources: src/helper/dev/index.ts:76-79

Route formatting call chain

The showRoutes function relies on inspectRoutes to print a formatted summary of all registered application endpoints to the standard output.

Sources: src/helper/dev/index.ts:23-74

  1. showRoutes invokes inspectRoutes to retrieve the complete array of RouteData records for the application instance.
  2. inspectRoutes maps over each registered route, evaluating findTargetHandler and calling handlerName to resolve names.
  3. handlerName inspects the target handler function, returning handler.name if present or falling back to [middleware] or [handler] based on isMiddleware(handler).

Warning

By default, showRoutes filters out middleware entries unless the verbose option is explicitly set to true.

Sources: src/helper/dev/index.ts:39-47

Development options reference

The formatting behavior of route inspection output is controlled via configuration options accepted by showRoutes.

Option PropertyTypeDefaultPurpose
verbosebooleanundefinedWhen enabled, includes middleware entries and prints individual handler names beneath each route.
colorizebooleangetColorEnabled()Toggles ANSI color formatting for printed HTTP methods in the console output.

Tip

Pass { verbose: true, colorize: false } to showRoutes when generating text-only build logs or debugging middleware chain execution order in non-TTY environments.

Sources: src/helper/dev/index.ts:11-14, src/helper/dev/index.ts:39-40

Execution context and environment mocking

Injecting bindings and execution context

The testClient helper function delegates incoming test requests to the underlying Hono application instance by invoking app.request(input, init, Env, executionCtx). This mechanism allows developers to supply mocked environment variables, secrets, KV namespaces, D1 databases, and R2 buckets directly during testing without starting an actual network server.

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)
  }

Sources: src/helper/testing/index.ts:16-24

Note

The Env parameter is extracted from the Hono application type via ExtractEnv<T>['Bindings'] or falls back to an empty object {} when no explicit environment schema is provided.

Sources: src/helper/testing/index.ts:13-18

Request dispatching and context initialization

When app.request() receives a URL string, pathname, or Request object, it normalizes the input into a standard Web Standard Request instance and passes it along with the mock environment and execution context to .fetch(). The internal #dispatch method then initializes a new Execution Context instance (Context) with the resolved path, routing match results, environment bindings, and execution context.

#dispatch(
  request: Request,
  executionCtx: ExecutionContext | FetchEventLike | undefined,
  env: E['Bindings'],
  method: string
): Response | Promise<Response> {
  // Handle HEAD method
  if (method === 'HEAD') {
    return (async () =>
      new Response(null, await this.#dispatch(request, executionCtx, env, 'GET')))()
  }
 
  const path = this.getPath(request, { env })
  const matchResult = this.router.match(method, path)
 
  const c = new Context(request, {
    path,
    matchResult,
    env,
    executionCtx,
    notFoundHandler: this.#notFoundHandler,
  })

Sources: src/hono-base.ts:406-427

Tip

Use .request() directly on any Application Core instance to pass custom headers, method parameters, and mock execution contexts for comprehensive unit testing of individual endpoints.

Sources: src/hono-base.ts:499-517

Handler profiling with server timing

Measuring latency with timing utilities

Hono provides a built-in server timing middleware and helper utilities to measure and profile middleware and handler latency during development. The timing middleware initializes request metrics and timers on the Execution Context, recording durations and automatically appending Server-Timing headers and optional Timing-Allow-Origin headers to the response.

export const timing = (config?: TimingOptions): MiddlewareHandler => {
  const options: TimingOptions = {
    total: true,
    enabled: true,
    totalDescription: 'Total Response Time',
    autoEnd: true,
    crossOrigin: false,
    ...config,
  }
  return async function timing(c, next) {
    const headers: string[] = []
    const timers = new Map<string, Timer>()
 
    if (c.get('metric')) {
      return await next()
    }
 
    c.set('metric', { headers, timers })
 
    if (options.total) {
      startTime(c, 'total', options.totalDescription)
    }
    await next()
 
    if (options.total) {
      endTime(c, 'total')
    }
 
    if (options.autoEnd) {
      timers.forEach((_, key) => {
        endTime(c, key)
      })
    }
 
    const enabled = typeof options.enabled === 'function' ? options.enabled(c) : options.enabled
 
    if (enabled) {
      c.res.headers.append('Server-Timing', headers.join(','))
 
      const crossOrigin =
        typeof options.crossOrigin === 'function' ? options.crossOrigin(c) : options.crossOrigin
 
      if (crossOrigin) {
        c.res.headers.append(
          'Timing-Allow-Origin',
          typeof crossOrigin === 'string' ? crossOrigin : '*'
        )
      }
    }
  }
}

Sources: src/middleware/timing/timing.ts:76-126

Note

getTime() falls back to Date.now() if performance.now() is unavailable in the current runtime environment.

Sources: src/middleware/timing/timing.ts:30-35

Configuration options reference

The behavior of the timing middleware is customized through configuration options passed to timing(config).

Option PropertyTypeDefaultPurpose
totalbooleantrueTracks and outputs the total response time metric.
enabledboolean | ((c: Context) => boolean)trueDetermines whether timing headers are appended to the response.
totalDescriptionstring'Total Response Time'Custom description text for the total response time metric.
autoEndbooleantrueAutomatically calls endTime on all active timers at the conclusion of the request.
crossOriginboolean | string | ((c: Context) => boolean | string)falseControls the Timing-Allow-Origin header value for cross-origin metrics visibility.

Warning

If the timing middleware has not been added to the route or application, calling setMetric, startTime, or endTime outputs a console warning and returns without recording metrics.

Sources: src/middleware/timing/timing.ts:22-28, src/middleware/timing/timing.ts:156-160

Manual profiling and promise wrapping

Developers can manually start and end named timers around specific blocks of work using startTime and endTime, or capture promise execution durations cleanly using wrapTime.

export const startTime = (c: Context, name: string, description?: string) => {
  const metrics = c.get('metric')
  if (!metrics) {
    console.warn('Metrics not initialized! Please add the `timing()` middleware to this route!')
    return
  }
  metrics.timers.set(name, { description, start: getTime() })
}
 
/**
 * End a timer for the timing middleware.
 *
 * @param {Context} c - The context of the request.
 * @param {string} name - The name of the timer.
 * @param {number} [precision] - The precision of the timer value.
 *
 * @example
 * ```ts
 * endTime(c, 'db')
 * ```
 */
export const endTime = (c: Context, name: string, precision?: number) => {
  const metrics = c.get('metric')
  if (!metrics) {
    console.warn('Metrics not initialized! Please add the `timing()` middleware to this route!')
    return
  }
  const timer = metrics.timers.get(name)
  if (!timer) {
    console.warn(`Timer "${name}" does not exist!`)
    return
  }
  const { description, start } = timer
 
  const duration = getTime() - start
 
  setMetric(c, name, duration, description, precision)
  metrics.timers.delete(name)
}
 
/**
 * Wrap a Promise to capture its duration.
 * @param {Context} c - The context of the request.
 * @param {string} name - The name of the timer.
 * @param {Promise<T>} callable - The Promise to time.
 * @param {string} [description] - The description of the timer.
 * @param {number} [precision] - The precision of the timer value.
 *
 * @example
 * ```ts
 *   // Instead of this:
 *   const data = await db.findMany(...);
 *
 *   // do this:
 *   const data = await wrapTime(c, 'query', db.findMany(...));
 * ```
 * */
export async function wrapTime<T>(
  c: Context,
  name: string,
  callable: Promise<T>,
  description?: string,
  precision?: number
): Promise<T> {
  startTime(c, name, description)
  try {
    return await callable
  } finally {
    endTime(c, name, precision)
  }

Sources: src/middleware/timing/timing.ts:187-256

Tip

Use wrapTime(c, 'query', db.findMany(...)) instead of manual startTime and endTime calls to automatically handle asynchronous boundaries and ensure timers close correctly even if promises reject.

Sources: src/middleware/timing/timing.ts:244-257

Was this page helpful?