A framework that promises "runs everywhere" has to prove it somewhere. Hono's answer is a test matrix with three tiers: unit tests colocated in src/, Vitest projects that boot real runtime emulators, and native test runners for Deno and Bun. All of it is wired through vitest.config.ts and the test:* scripts.

Tier 1: colocated unit tests

Every module in src/ has a sibling .test.ts. The main Vitest project compiles them with Hono's own JSX (importSource: './src/jsx'), so even the JSX runtime is tested by using it:

vitest.config.ts
{
  oxc: {
    jsx: {
      runtime: 'automatic',
      importSource: './src/jsx',
    },
  },
  extends: true,
  test: {
    exclude: [...configDefaults.exclude, '**/sandbox/**', '**/*.case.test.*'],
    include: [
      'src/**/(*.)+(spec|test).+(ts|tsx|js)',
      // …
    ],
    name: 'main',
  },
},

Two additional projects (jsx-runtime-default, jsx-runtime-dom) run the DOM-side JSX tests twice with different importSource values -- once against src/jsx, once against src/jsx/dom -- guaranteeing both entry points implement the same hooks contract. jsdom (devDependency) provides the DOM.

Tier 2: real runtimes inside Vitest

vitest.config.ts pulls in ./runtime-tests/*/vitest.config.ts as extra projects: node, fastly, workerd, lambda, lambda-edge. These are not mocks. The workerd suite starts an actual workerd instance through wrangler's dev API and fetches against it:

runtime-tests/workerd/index.test.ts
beforeAll(async () => {
  worker = await unstable_dev('./runtime-tests/workerd/index.ts', {
    vars: {
      NAME: 'Hono',
    },
    compatibilityDate: '2026-07-01',
    experimental: { disableExperimentalWarning: true },
  })
})
// …
it('Should return 200 response with the runtime key', async () => {
  const res = await worker.fetch('/')
  expect(res.status).toBe(200)
  expect(await res.text()).toBe('Hello from workerd')

This is why wrangler, @hono/node-server, and vite-plugin-fastly-js-compute sit in devDependencies: each exists to boot one runtime tier. msw mocks outbound network calls where tests need a remote server; ws backs the Node/workerd WebSocket tests.

Tier 3: native runners

Deno and Bun run their own test frameworks against runtime-tests/deno* and runtime-tests/bun:

package.json
"test:deno": "deno test --allow-read --allow-env --allow-write --allow-net -c runtime-tests/deno/deno.json runtime-tests/deno && deno test --no-lock -c runtime-tests/deno-jsx/deno.precompile.json runtime-tests/deno-jsx && …",
"test:bun": "bun test --jsx-import-source ../../src/jsx runtime-tests/bun/*",
"test:all": "bun run test && bun test:deno && bun test:bun",

The deno-jsx suite runs twice under two different Deno JSX configs (precompile and react-jsx), because Deno's JSX precompile mode exercises a different code path in Hono's JSX runtime.

Cross-cutting guarantees

  • Router equivalence: src/router/common.case.test.ts is a shared behavioral spec executed against all router implementations, which is what allows SmartRouter to swap them freely (see Routing).
  • Types are tested: bun run test runs tsc -p tsconfig.spec.json before Vitest, so *.test.ts files double as type-assertion suites.
  • Coverage is measured with v8 (@vitest/coverage-v8) and reported to codecov; type-only files are explicitly excluded in vitest.config.ts since they emit no runtime code.

For the app-developer view of testing -- app.request() and the typed testClient -- see the cookbook recipe.

Sources: vitest.config.ts, runtime-tests/workerd/index.test.ts, package.json · last synced 2026-08-10 · 26de731 · version 4.13.1