Express vs Hono: A Comprehensive Comparison for 2026
In the ever-evolving landscape of Node.js web frameworks, two names stand out in 2026: Express, the battle-tested veteran that has powered millions of applications for over a decade, and Hono, the lightning-fast challenger that has rapidly gained adoption across multiple JavaScript runtimes. This comprehensive guide will walk you through everything you need to know to make an informed decision for your next project.
What You'll Learn
- The architectural differences between Express and Hono
- Performance benchmarks and real-world implications
- Complete code examples for building REST APIs in both frameworks
- Middleware patterns and best practices
- Edge computing and multi-runtime deployment strategies
- Migration paths from Express to Hono
1. Understanding the Fundamentals
๐ Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →1.1 What is Express?
Express.js is the de facto standard web framework for Node.js, first released in 2010. It provides a minimalist, unopinionated layer of abstraction over Node's native HTTP module. Express uses a sequential middleware chain pattern where each middleware function receives req, res, and a next callback. With over 30 million weekly npm downloads, it remains the most widely used Node.js framework in 2026, backed by a massive ecosystem of middleware and community packages.
1.2 What is Hono?
Hono (meaning "flame" in Japanese) emerged in 2022 as a lightweight, ultrafast web framework designed for multiple JavaScript runtimes including Cloudflare Workers, Deno, Bun, Node.js, and AWS Lambda. Unlike Express, Hono was built from the ground up with the Web Standard API in mind, using Request and Response objects natively rather than Node.js-specific abstractions. By 2026, Hono has matured significantly with v4.x offering stable APIs, built-in validation via Zod integration, JSX middleware for server-side rendering, and first-class edge computing support.
1.3 Why This Comparison Matters in 2026
The JavaScript runtime landscape has transformed dramatically. Bun has reached 1.x stability, Deno has gained significant enterprise adoption, and Cloudflare Workers now process over a trillion requests daily. Choosing a framework that aligns with your runtime strategy is no longer optionalโit's foundational. Express locks you into Node.js (and partially Deno via compatibility layers), while Hono offers genuine portability across runtimes. Understanding these trade-offs will directly impact your application's performance, deployment flexibility, and long-term maintainability.
2. Architectural Deep Dive
2.1 Middleware Execution Models
Both frameworks use middleware composition, but their implementations differ fundamentally. Express uses a sequential callback chain with explicit next() invocation. Hono uses a more modern approach inspired by Koa's onion model but implemented with async/await and Web Standard APIs.
Express Middleware Example:
// Express middleware - callback-based with next()
const express = require('express');
const app = express();
// Logger middleware
app.use((req, res, next) => {
const start = Date.now();
console.log(`${req.method} ${req.url}`);
// Monkey-patch response end to log duration
const originalEnd = res.end;
res.end = function(...args) {
console.log(`Response time: ${Date.now() - start}ms`);
originalEnd.apply(res, args);
};
next();
});
// Error-handling middleware (4 parameters)
app.use((err, req, res, next) => {
console.error('Error:', err.message);
res.status(500).json({ error: err.message });
});
app.get('/api/users', (req, res) => {
res.json({ users: [{ id: 1, name: 'Alice' }] });
});
app.listen(3000, () => console.log('Express on port 3000'));
Hono Middleware Example:
// Hono middleware - async/await with Web Standard APIs
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
const app = new Hono();
// Built-in logger middleware
app.use(logger());
// CORS middleware with configuration
app.use('/api/*', cors({
origin: ['https://example.com'],
allowMethods: ['GET', 'POST', 'PUT'],
maxAge: 86400,
}));
// Custom timing middleware
app.use(async (c, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
c.res.headers.set('X-Response-Time', `${duration}ms`);
});
// Error handling
app.onError((err, c) => {
console.error(`Error: ${err.message}`);
return c.json({ error: err.message }, 500);
});
app.get('/api/users', (c) => {
return c.json({ users: [{ id: 1, name: 'Alice' }] });
});
export default app;
2.2 The Context Object Pattern
The most significant architectural difference is how each framework handles the request/response lifecycle. Express uses separate req and res objects passed through closures. Hono uses a unified Context object (conventionally named c) that bundles request, response, and framework utilities together.
This single-context pattern in Hono eliminates the need to juggle multiple parameters, simplifies TypeScript typing dramatically, and makes middleware composition more predictable. The context object is freshly created per request, avoiding the shared-state pitfalls that can occur with Express's mutable response object.
2.3 Routing Capabilities Compared
Express routing is pattern-based with support for parameters, optional parameters, and limited regex capabilities. Hono offers a more sophisticated routing system with named parameters, wildcards, regex constraints, and even route grouping.
// Express routing
const express = require('express');
const router = express.Router();
// Parameter routes
router.get('/users/:id', (req, res) => {
res.json({ id: req.params.id });
});
// Optional parameters (custom logic needed)
router.get('/posts/:year?/:month?', (req, res) => {
res.json({ year: req.params.year, month: req.params.month });
});
// Regex-based matching
router.get(/^\/items\/(\d+)$/, (req, res) => {
res.json({ itemId: req.params[0] });
});
app.use('/v1', router);
// Hono routing
import { Hono } from 'hono';
const app = new Hono();
// Named parameters with TypeScript inference
app.get('/users/:id', (c) => {
const id = c.req.param('id'); // typed as string
return c.json({ id });
});
// Wildcard and multiple parameters
app.get('/posts/:year/:month', (c) => {
return c.json({
year: c.req.param('year'),
month: c.req.param('month')
});
});
// Route grouping with prefix
const api = new Hono();
api.get('/health', (c) => c.text('OK'));
api.get('/status', (c) => c.json({ status: 'operational' }));
// Nest with prefix
app.route('/api', api);
// Regex routes using validators
app.get('/items/:id{number}', (c) => {
// id is guaranteed to be numeric
return c.json({ itemId: parseInt(c.req.param('id')) });
});
3. Performance Analysis
3.1 Benchmark Comparisons
Independent benchmarks consistently show Hono outperforming Express by significant margins. In throughput tests using wrk and autocannon, Hono typically handles 3-5x more requests per second than Express on equivalent Node.js hardware. This advantage stems from Hono's lightweight core, efficient regex-based routing (using the RegExpRouter internally), and minimal overhead from Web Standard API alignment.
Sample Benchmark Setup:
// Benchmark using autocannon
// Install: npm install -g autocannon
// Express server (express-server.js)
const express = require('express');
const app = express();
app.get('/hello', (req, res) => res.json({ message: 'Hello World' }));
app.listen(3000);
// Hono server (hono-server.js)
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
const app = new Hono();
app.get('/hello', (c) => c.json({ message: 'Hello World' }));
serve({ fetch: app.fetch, port: 3001 });
// Run benchmarks:
// autocannon -c 100 -d 30 http://localhost:3000/hello
// autocannon -c 100 -d 30 http://localhost:3001/hello
//
// Typical results (Node.js 22, 2026):
// Express: ~45k req/sec
// Hono: ~120k req/sec
3.2 Cold Start Performance
For serverless and edge deployments, cold start time is critical. Express applications bundled for Lambda typically have 150-300ms cold starts due to the framework's initialization overhead and the need to parse route tables at startup. Hono's cold starts average 10-40ms because its routing compilation is more efficient and its core footprint is dramatically smaller (approximately 14KB minified vs Express's 60KB+). On Cloudflare Workers, Hono cold starts are essentially zero since the runtime uses isolates rather than containers.
3.3 Memory Footprint
Express applications in production typically consume 30-60MB of memory baseline. Hono applications running the same workloads average 8-18MB. This 3-4x memory efficiency makes Hono particularly attractive for containerized deployments where memory costs directly translate to infrastructure expenses.
4. Building a Complete REST API
4.1 Express Implementation
Below is a production-ready REST API built with Express, demonstrating validation, error handling, database integration, and structured logging.
// express-api.js - Complete REST API with Express
const express = require('express');
const { z } = require('zod');
const crypto = require('crypto');
const app = express();
// --- Middleware Setup ---
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request ID middleware
app.use((req, res, next) => {
req.requestId = crypto.randomUUID();
res.setHeader('X-Request-Id', req.requestId);
next();
});
// Structured logging
app.use((req, res, next) => {
const log = {
requestId: req.requestId,
method: req.method,
url: req.originalUrl,
timestamp: new Date().toISOString(),
};
console.log(JSON.stringify(log));
next();
});
// --- Validation Schemas (Zod) ---
const CreateUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['admin', 'user', 'editor']).default('user'),
});
const UpdateUserSchema = CreateUserSchema.partial();
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
});
// --- In-memory database simulation ---
const users = new Map();
let idCounter = 1;
// --- Validation middleware factory ---
function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
req.validated = result.data;
next();
};
}
// --- Routes ---
// GET /api/users - List users with pagination
app.get('/api/users', (req, res) => {
const queryResult = QuerySchema.safeParse(req.query);
if (!queryResult.success) {
return res.status(400).json({
error: 'Invalid query parameters',
details: queryResult.error.issues,
});
}
const { page, limit, sort } = queryResult.data;
const allUsers = Array.from(users.values());
// Sort users
allUsers.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'email') return a.email.localeCompare(b.email);
return a.createdAt.localeCompare(b.createdAt);
});
// Apply pagination
const start = (page - 1) * limit;
const paginatedUsers = allUsers.slice(start, start + limit);
res.json({
data: paginatedUsers,
pagination: {
page,
limit,
total: allUsers.length,
totalPages: Math.ceil(allUsers.length / limit),
},
});
});
// GET /api/users/:id - Get single user
app.get('/api/users/:id', (req, res) => {
const user = users.get(parseInt(req.params.id));
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ data: user });
});
// POST /api/users - Create user
app.post('/api/users', validate(CreateUserSchema), (req, res) => {
const id = idCounter++;
const user = {
id,
...req.validated,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
users.set(id, user);
res.status(201).json({ data: user });
});
// PUT /api/users/:id - Update user
app.put('/api/users/:id', validate(UpdateUserSchema), (req, res) => {
const id = parseInt(req.params.id);
const existing = users.get(id);
if (!existing) {
return res.status(404).json({ error: 'User not found' });
}
const updated = {
...existing,
...req.validated,
updatedAt: new Date().toISOString(),
};
users.set(id, updated);
res.json({ data: updated });
});
// DELETE /api/users/:id - Delete user
app.delete('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
if (!users.has(id)) {
return res.status(404).json({ error: 'User not found' });
}
users.delete(id);
res.status(204).send();
});
// --- Global error handler ---
app.use((err, req, res, next) => {
console.error(`Error [${req.requestId}]:`, err.stack);
res.status(500).json({
error: 'Internal Server Error',
requestId: req.requestId,
});
});
// --- Start server ---
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Express API running on port ${PORT}`);
});
module.exports = app;
4.2 Hono Implementation
Here is the equivalent REST API built with Hono, showcasing its cleaner context-based pattern, built-in validation helpers, and multi-runtime compatibility.
// hono-api.ts - Complete REST API with Hono
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { logger } from 'hono/logger';
import { prettyJSON } from 'hono/pretty-json';
import { cors } from 'hono/cors';
import { HTTPException } from 'hono/http-exception';
// --- Type definitions ---
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'editor';
createdAt: string;
updatedAt: string;
}
// --- Validation Schemas ---
const CreateUserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['admin', 'user', 'editor']).default('user'),
});
const UpdateUserSchema = CreateUserSchema.partial();
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
});
// --- Initialize Hono ---
const app = new Hono();
// --- Global Middleware ---
app.use(logger());
app.use(prettyJSON());
app.use('/api/*', cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
exposeHeaders: ['X-Request-Id'],
}));
// Request ID middleware
app.use(async (c, next) => {
const requestId = crypto.randomUUID();
c.set('requestId', requestId);
c.res.headers.set('X-Request-Id', requestId);
await next();
});
// --- In-memory database ---
const users = new Map();
let idCounter = 1;
// --- Routes (using Hono's chained methods) ---
// GET /api/users - List with pagination
app.get('/api/users', zValidator('query', QuerySchema), (c) => {
const { page, limit, sort } = c.req.valid('query');
const allUsers = Array.from(users.values());
allUsers.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'email') return a.email.localeCompare(b.email);
return a.createdAt.localeCompare(b.createdAt);
});
const start = (page - 1) * limit;
const paginatedUsers = allUsers.slice(start, start + limit);
return c.json({
success: true,
data: paginatedUsers,
pagination: {
page,
limit,
total: allUsers.length,
totalPages: Math.ceil(allUsers.length / limit),
},
});
});
// GET /api/users/:id - Get single user
app.get('/api/users/:id', (c) => {
const id = parseInt(c.req.param('id'));
const user = users.get(id);
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json({ success: true, data: user });
});
// POST /api/users - Create user
app.post('/api/users', zValidator('json', CreateUserSchema), (c) => {
const body = c.req.valid('json');
const id = idCounter++;
const now = new Date().toISOString();
const user: User = {
id,
...body,
createdAt: now,
updatedAt: now,
};
users.set(id, user);
return c.json({ success: true, data: user }, 201);
});
// PUT /api/users/:id - Update user
app.put('/api/users/:id', zValidator('json', UpdateUserSchema), (c) => {
const id = parseInt(c.req.param('id'));
const existing = users.get(id);
if (!existing) {
throw new HTTPException(404, { message: 'User not found' });
}
const body = c.req.valid('json');
const updated: User = {
...existing,
...body,
updatedAt: new Date().toISOString(),
};
users.set(id, updated);
return c.json({ success: true, data: updated });
});
// DELETE /api/users/:id - Delete user
app.delete('/api/users/:id', (c) => {
const id = parseInt(c.req.param('id'));
if (!users.has(id)) {
throw new HTTPException(404, { message: 'User not found' });
}
users.delete(id);
return c.body(null, 204);
});
// --- Health check (no auth required) ---
app.get('/health', (c) => c.json({
status: 'healthy',
timestamp: new Date().toISOString(),
runtime: typeof Deno !== 'undefined' ? 'Deno' :
typeof Bun !== 'undefined' ? 'Bun' : 'Node.js',
}));
// --- Global error handler ---
app.onError((err, c) => {
const requestId = c.get('requestId');
console.error(`Error [${requestId}]:`, err.message);
if (err instanceof HTTPException) {
return c.json({
success: false,
error: err.message,
requestId,
}, err.status);
}
// Handle Zod validation errors
if (err.name === 'ZodError') {
return c.json({
success: false,
error: 'Validation failed',
details: err.issues,
requestId,
}, 400);
}
return c.json({
success: false,
error: 'Internal Server Error',
requestId,
}, 500);
});
// --- 404 handler ---
app.notFound((c) => {
return c.json({
success: false,
error: `Route not found: ${c.req.method} ${c.req.url}`,
}, 404);
});
export default app;
// --- Server entry points by runtime ---
// Node.js: import { serve } from '@hono/node-server'; serve(app);
// Bun: export default { fetch: app.fetch };
// Deno: Deno.serve(app.fetch);
// Cloudflare Workers: export default app;
5. Middleware Ecosystem
5.1 Express Middleware Landscape
Express boasts over 3,000 middleware packages on npm. Key categories include authentication (Passport.js with 500+ strategies), rate limiting (express-rate-limit), compression (compression), security headers (helmet), session management (express-session), and file uploads (multer). However, many popular Express middleware packages have gone unmaintained, relying on outdated patterns like monkey-patching and synchronous callbacks.
5.2 Hono's Built-in Middleware Suite
Hono ships with an extensive collection of officially maintained middleware that covers most common needs without external dependencies. These are tree-shakeable, TypeScript-native, and tested across all supported runtimes.
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { csrf } from 'hono/csrf';
import { jwt } from 'hono/jwt';
import { etag } from 'hono/etag';
import { compress } from 'hono/compress';
import { timeout } from 'hono/timeout';
import { ipRestriction } from 'hono/ip-restriction';
import { poweredBy } from 'hono/powered-by';
import { secureHeaders } from 'hono/secure-headers';
import { cache } from 'hono/cache';
const app = new Hono();
// Logging with colored output and timing
app.use(logger());
// Security headers (equivalent to helmet)
app.use(secureHeaders());
// CORS with strict configuration
app.use('/api/*', cors({
origin: (origin) => origin.endsWith('.example.com') ? origin : null,
credentials: true,
}));
// CSRF protection for form submissions
app.use('/form/*', csrf());
// JWT authentication for protected routes
const protected = new Hono();
protected.use('/admin/*', jwt({
secret: process.env.JWT_SECRET!,
}));
protected.get('/admin/dashboard', (c) => {
const payload = c.get('jwtPayload');
return c.json({ message: `Welcome ${payload.sub}` });
});
app.route('/', protected);
// ETag caching for static-like resources
app.use('/assets/*', etag());
// Response compression
app.use(compress());
// Timeout protection (15 seconds)
app.use('/api/*', timeout(15000));
// IP-based rate limiting
app.use('/api/*', ipRestriction({
denyList: ['192.168.1.100'],
allowList: ['10.0.0.0/8'],
}));
// Remove X-Powered-By header
app.use(poweredBy({ server: false }));
6. TypeScript Experience
6.1 Express and TypeScript
Express was not designed for TypeScript. Type definitions are maintained externally via DefinitelyTyped (@types/express). The req and res objects require manual type augmentation for custom properties, and route parameters lack automatic inference. This leads to verbose type gymnastics:
// Express TypeScript - manual type work
import express, { Request, Response, NextFunction } from 'express';
// Augment Request type globally
declare global {
namespace Express {
interface Request {
requestId?: string;
validated?: any;
}
}
}
const app = express();
// Route params need manual typing
app.get('/users/:id', (
req: Request<{ id: string }>,
res: Response
) => {
// req.params.id is typed as string, but no validation guarantee
const id = parseInt(req.params.id);
res.json({ id });
});
// Middleware type gymnastics
interface AuthenticatedRequest extends Request {
user?: { id: number; role: string };
}
app.use((req: AuthenticatedRequest, res: Response, next: NextFunction) => {
req.user = { id: 1, role: 'admin' };
next();
});
6.2 Hono's First-Class TypeScript Support
Hono is written in TypeScript from the ground up. Route parameters are automatically inferred, the context object is fully typed per-route, and validators integrate seamlessly with Zod for end-to-end type safety.
// Hono TypeScript - first-class types
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const app = new Hono();
// Route params are automatically typed
app.get('/users/:id', (c) => {
// c.req.param('id') returns string
const id = c.req.param('id');
return c.json({ id });
});
// Validator integrates types seamlessly
const CreateSchema = z.object({
name: z.string(),
email: z.string().email(),
});
app.post('/users', zValidator('json', CreateSchema), (c) => {
// c.req.valid('json') is fully typed as { name: string; email: string }
const body = c.req.valid('json');
// TypeScript knows body.name and body.email exist
return c.json({ created: body });
});
// Custom context variables are type-safe
declare module 'hono' {
interface ContextVariableMap {
userId: number;
requestId: string;
}
}
app.use(async (c, next) => {
c.set('userId', 42);
c.set('requestId', crypto.randomUUID());
await next();
});
app.get('/me', (c) => {
// Fully typed access
const userId = c.get('userId'); // number
const requestId = c.get('requestId'); // string
return c.json({ userId, requestId });
});
7. Edge Computing and Multi-Runtime Deployment
7.1 The Edge Computing Revolution
By 2026, edge computing has moved from experimental to mainstream. Organizations deploy code to 300+ data centers worldwide, serving users with sub-10ms latency. Frameworks that cannot operate at the edge are increasingly relegated to legacy backend services. Hono was purpose-built for this paradigm, while Express requires adaptation layers.
7.2 Deploying Hono Across Runtimes
One of Hono's killer features is write-once, deploy-anywhere portability. The same application code runs on Cloudflare Workers, Deno Deploy, Bun, AWS Lambda, and traditional Node.js servers without modification.
// app.ts - Same code, multiple runtimes
import { Hono } from 'hono';
import { cors } from 'hono/cors';
const app = new Hono();
app.use(cors());
app.get('/', (c) => c.json({ message: 'Hello from the edge!' }));
app.get('/runtime', (c) => {
// Detect runtime without runtime-specific imports
const runtime = typeof Deno !== 'undefined' ? 'Deno' :
typeof Bun !== 'undefined' ? 'Bun' :
typeof Cloudflare !== 'undefined' ? 'Cloudflare Workers' :
'Node.js';
return c.json({ runtime });
});
export default app;
// --- Deployment Configurations ---
// Node.js deployment (server.ts)
import { serve } from '@hono/node-server';
import app from './app';
serve({ fetch: app.fetch, port: 3000 });
// Bun deployment (no changes needed)
// bun run --hot server.ts OR directly:
// export default { fetch: app.fetch, port: 3000 };
// Deno deployment
// deno run --allow-net server-deno.ts:
import app from './app';
Deno.serve({ port: 3000 }, app.fetch);
// Cloudflare Workers deployment (wrangler.toml):
// name = "my-hono-api"
// main = "src/app.ts"
// compatibility_date = "2026-01-01"
//
// Deploy: npx wrangler deploy
// AWS Lambda via @hono/aws-lambda
import { handle } from '@hono/aws-lambda';
import app from './app';
export const handler = handle(app);
7.3 Express at the Edge (Limitations)
Running Express on edge platforms requires polyfills and adapter layers. Projects like @cloudflare/express-platform exist but add latency and complexity. Express's reliance on Node.js-specific APIs (req.socket, res.writeHead, event emitters) makes it fundamentally incompatible with the Web Standard fetch-based runtime model used by Workers and Deno Deploy.
// Running Express on Cloudflare Workers (adapter approach)
import express from 'express';
import { createRequestHandler } from '@cloudflare/express-platform';
const app = express();
app.get('/', (req, res) => res.json({ ok: true }));
// Adapter wraps Express for Workers fetch API
export default {
fetch: createRequestHandler(app, {
// Limited functionality: no streaming, no websockets,
// middleware compatibility varies
}),
};
// Caveats:
// - No res.sendFile() (no filesystem)
// - No express.static()
// - Memory limits per Worker isolate
// - Cold start penalty from adapter overhead
// - Many middleware packages incompatible
8. Testing Strategies
8.1 Testing Express Applications
Express testing traditionally uses supertest, which spins up a real HTTP server and makes requests against it. This approach is integration-test heavy and relatively slow.
// Express testing with supertest and jest
const request = require('supertest');
const app = require('./express-api');
describe('GET /api/users', () => {
it('should return paginated users', async () => {
const res = await request(app)
.get('/api/users')
.query({ page: 1, limit: 10 })
.expect(200);
expect(res.body).toHaveProperty('data');
expect(res.body).toHaveProperty('pagination');
expect(res.body.pagination.page).toBe(1);
});
it('should reject invalid query params', async () => {
const res = await request(app)
.get('/api/users')
.query({ page: 'invalid' })
.expect(400);
expect(res.body.error).toBe('Invalid query parameters');
});
});
describe('POST /api/users', () => {
it('should create a user with valid data', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@example.com', role: 'user' })
.expect(201);
expect(res.body.data.name).toBe('Alice');
expect(res.body.data.id).toBeDefined();
});
it('should reject missing required fields', async () => {
await request(app)
.post('/api/users')
.send({ name: 'Bob' }) // missing email
.expect(400);
});
});
8.2 Testing Hono Applications
Hono provides a built-in testRequest utility that tests the app.fetch method directly without spinning up a server. This enables faster, unit-style testing of routes and middleware.
// Hono testing with built-in test utilities
import { Hono } from 'hono';
import { testRequest } from 'hono/testing';
import { describe, it, expect } from 'vitest';
import app from './hono-api';
describe('GET /api/users', () => {
it('should return paginated users', async () => {
const res = await testRequest(app)
.get('/api/users?page=1&limit=10');
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
expect(body).toHaveProperty('pagination');
expect(body.pagination.page).toBe(1);
});
it('should reject invalid query params', async () => {
const res = await testRequest(app)
.get('/api/users?page=invalid');
expect(res.status).toBe(400);
const body = await res.json();
expect(body.success).toBe(false);
});
});
describe('POST /api/users', () => {
it('should create a user', async () => {
const res = await testRequest(app)
.post('/api/users')
.json({ name: 'Alice', email: 'alice@example.com', role: 'user' });
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data.name).toBe('Alice');
});
it('should validate request body', async () => {
const res = await testRequest(app)
.post('/api/users')
.json({ name: 'Bob