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:
{
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:
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:
"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.tsis a shared behavioral spec executed against all router implementations, which is what allowsSmartRouterto swap them freely (see Routing). - Types are tested:
bun run testrunstsc -p tsconfig.spec.jsonbefore Vitest, so*.test.tsfiles double as type-assertion suites. - Coverage is measured with v8 (
@vitest/coverage-v8) and reported to codecov; type-only files are explicitly excluded invitest.config.tssince they emit no runtime code.
For the app-developer view of testing -- app.request() and the typed testClient -- see the cookbook recipe.