How to Test Hono Apps: Unit and Integration Testing Guide
Hono is the fastest growing TypeScript web framework you might not be testing properly. It runs on Cloudflare Workers, Bun, Deno, and Node.js — the same Hono app, different runtimes. The multiplatform design is elegant. The testing story is underserved.
Here's how to build a proper Hono test suite.
Why Hono Testing Is Different
Hono's design is runtime-agnostic. That's its superpower and its testing nuance: you need to test your application logic without spinning up a real server, and you need to verify it works on the target runtime.
The good news: Hono's app.request() method makes this straightforward.
Setting Up the Test Environment
npm install --save-dev vitest @vitest/coverage-v8
# or
bun add -d vitest @vitest/coverage-v8// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});Layer 1: Testing Routes with app.request()
app.request() is Hono's built-in test helper. It fires a real Request through your handler chain and returns the Response — no HTTP server required.
// src/app.ts
import { Hono } from 'hono';
const app = new Hono();
app.get('/health', (c) => c.json({ status: 'ok' }));
app.get('/users/:id', async (c) => {
const id = c.req.param('id');
const user = await getUserById(Number(id));
if (!user) return c.json({ error: 'User not found' }, 404);
return c.json(user);
});
app.post('/users', async (c) => {
const body = await c.req.json();
if (!body.email) return c.json({ error: 'email required' }, 400);
const user = await createUser(body);
return c.json(user, 201);
});
export default app;// src/app.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import app from './app';
vi.mock('./services/users', () => ({
getUserById: vi.fn(),
createUser: vi.fn(),
}));
import { getUserById, createUser } from './services/users';
describe('GET /health', () => {
it('returns 200 with status ok', async () => {
const res = await app.request('/health');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.status).toBe('ok');
});
});
describe('GET /users/:id', () => {
it('returns the user when found', async () => {
(getUserById as any).mockResolvedValue({ id: 1, email: 'alice@test.com' });
const res = await app.request('/users/1');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.email).toBe('alice@test.com');
});
it('returns 404 when user does not exist', async () => {
(getUserById as any).mockResolvedValue(null);
const res = await app.request('/users/99999');
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe('User not found');
});
});
describe('POST /users', () => {
it('creates user with valid data', async () => {
(createUser as any).mockResolvedValue({ id: 5, email: 'new@test.com' });
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'new@test.com', name: 'Alice' }),
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.email).toBe('new@test.com');
});
it('returns 400 when email is missing', async () => {
const res = await app.request('/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice' }),
});
expect(res.status).toBe(400);
});
});Layer 2: Testing Middleware
Middleware is where auth, rate limiting, and logging live. Test it directly.
// src/middleware/auth.ts
import { Context, Next } from 'hono';
import { verify } from 'hono/jwt';
export async function jwtAuth(c: Context, next: Next) {
const token = c.req.header('Authorization')?.replace('Bearer ', '');
if (!token) return c.json({ error: 'Unauthorized' }, 401);
try {
const payload = await verify(token, c.env.JWT_SECRET);
c.set('user', payload);
await next();
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
}// src/middleware/auth.test.ts
import { describe, it, expect } from 'vitest';
import { Hono } from 'hono';
import { sign } from 'hono/jwt';
import { jwtAuth } from './auth';
describe('jwtAuth middleware', () => {
const testApp = new Hono();
testApp.use('/protected/*', jwtAuth);
testApp.get('/protected/data', (c) => c.json({ secret: 'data' }));
it('blocks requests without Authorization header', async () => {
const res = await testApp.request('/protected/data');
expect(res.status).toBe(401);
});
it('blocks requests with invalid tokens', async () => {
const res = await testApp.request('/protected/data', {
headers: { Authorization: 'Bearer invalid.jwt.token' },
});
expect(res.status).toBe(401);
});
it('allows requests with valid tokens', async () => {
const token = await sign({ sub: '1', role: 'user' }, 'test-secret');
const res = await testApp.request('/protected/data', {
headers: { Authorization: `Bearer ${token}` },
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.secret).toBe('data');
});
});Layer 3: Testing Hono RPC
Hono's type-safe RPC client is one of its most compelling features. Test both sides:
// src/routes/products.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const productSchema = z.object({
name: z.string().min(1),
price: z.number().positive(),
stock: z.number().int().min(0),
});
const products = new Hono()
.post('/', zValidator('json', productSchema), async (c) => {
const data = c.req.valid('json');
const product = await createProduct(data);
return c.json(product, 201);
})
.get('/:id', async (c) => {
const id = Number(c.req.param('id'));
const product = await getProduct(id);
if (!product) return c.json({ error: 'Not found' }, 404);
return c.json(product);
});
export type ProductsRoutes = typeof products;
export default products;// src/routes/products.test.ts
import { describe, it, expect, vi } from 'vitest';
import { hc } from 'hono/client';
import { Hono } from 'hono';
import products, { ProductsRoutes } from './products';
vi.mock('../services/products');
import { createProduct, getProduct } from '../services/products';
const testApp = new Hono().route('/products', products);
describe('Products routes', () => {
it('validates product schema — rejects missing name', async () => {
const res = await testApp.request('/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price: 29.99, stock: 10 }),
});
expect(res.status).toBe(400);
});
it('validates product schema — rejects negative price', async () => {
const res = await testApp.request('/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Widget', price: -5, stock: 10 }),
});
expect(res.status).toBe(400);
});
it('creates product with valid data', async () => {
(createProduct as any).mockResolvedValue({ id: 1, name: 'Widget', price: 29.99 });
const res = await testApp.request('/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Widget', price: 29.99, stock: 50 }),
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.name).toBe('Widget');
});
});Testing Cloudflare Workers Bindings
If your Hono app uses Cloudflare bindings (KV, D1, R2), inject them in tests:
// src/routes/cache.test.ts
import { describe, it, expect } from 'vitest';
import app from './app';
describe('KV-backed routes', () => {
// Mock the Cloudflare KV binding
const mockKV = {
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
it('returns cached value when present', async () => {
mockKV.get.mockResolvedValue(JSON.stringify({ result: 'cached' }));
const res = await app.request('/data/expensive-key', {}, {
CACHE_KV: mockKV, // Inject binding via env parameter
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.result).toBe('cached');
expect(mockKV.get).toHaveBeenCalledWith('expensive-key');
});
it('computes and caches when cache miss', async () => {
mockKV.get.mockResolvedValue(null);
const res = await app.request('/data/new-key', {}, {
CACHE_KV: mockKV,
});
expect(res.status).toBe(200);
expect(mockKV.put).toHaveBeenCalled();
});
});What Tests Miss
Your Vitest suite confirms your Hono routes handle requests correctly in Node.js. Deployed Hono apps can fail in runtime-specific ways:
- Cloudflare Workers CPU limits — a route that completes in 5ms in Node.js hits the 50ms CPU limit in Workers for the same logic
- Streaming response differences —
c.stream()behaves differently across runtimes - Edge cold starts — the first request to a Workers deployment has latency your tests never see
- KV eventual consistency — writes to Cloudflare KV don't immediately reflect in subsequent reads in the same Worker invocation
Monitoring Hono APIs in Production
HelpMeTest runs behavioral tests against your deployed Hono endpoint on a schedule:
Test: API health and basic CRUD
GET /health → status 200, body.status = "ok"
POST /users → status 201, response has id
GET /users/:id → status 200, returns created user
Response times under 200ms (edge should be fast)If your Cloudflare Workers deployment starts returning 500 errors, your D1 queries time out, or a runtime update changes response behavior, you know before users do.
Free tier: 10 tests, unlimited health checks. Try HelpMeTest →
Hono Testing Checklist
app.request()tests for every route: happy path, not found, validation errors- Middleware tests: auth, rate limiting, CORS headers
- Schema validation tests: valid payloads pass, invalid payloads return 400
- Cloudflare bindings mocked in tests: KV, D1, R2, environment variables
- Error handler tested: verify your error middleware returns correct format
- RPC type tests: TypeScript compilation catches type mismatches at the boundary
- Production monitoring: behavioral tests running after every Workers deploy
app.request() is your friend. Use it.