How to Test Hono Apps: Unit and Integration Testing Guide
Hono is the fastest growing TypeScript web framework you might not be testing properly.
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.
Usage-based pricing: $0.003/run, 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.