# Testing Patterns Type-safe testing with `testClient` — no HTTP server required. ## Basic Setup ```typescript import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { testClient } from 'hono/testing'; import { Hono } from 'hono'; import { Database } from 'bun:sqlite'; // Simple app for testing const createApp = () => { return new Hono() .get('/health', (c) => c.json({ status: 'ok' })) .get('/error', () => { throw new Error('Test error'); }); }; describe('Basic Tests', () => { test('GET /health returns 200', async () => { const app = createApp(); const client = testClient(app); const res = await client.health.$get(); expect(res.status).toBe(200); const data = await res.json(); expect(data).toEqual({ status: 'ok' }); }); test('GET /error returns 500', async () => { const app = createApp(); const client = testClient(app); const res = await client.error.$get(); expect(res.status).toBe(500); }); }); ``` ## Testing CRUD Operations ```typescript import { z } from 'zod'; import { zValidator } from '@hono/zod-validator'; const CreatePostSchema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1), }); describe('Posts API', () => { let db: Database; let app: Hono; let client: ReturnType; beforeEach(() => { // In-memory database for each test db = new Database(':memory:'); db.run(` CREATE TABLE posts ( id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); // Create app with database app = new Hono() .get('/posts', (c) => { const posts = db.query('SELECT * FROM posts').all(); return c.json({ posts }); }) .get('/posts/:id', (c) => { const post = db.query('SELECT * FROM posts WHERE id = ?').get(c.req.param('id')); if (!post) { throw new HTTPException(404, { message: 'Post not found' }); } return c.json({ post }); }) .post('/posts', zValidator('json', CreatePostSchema), (c) => { const data = c.req.valid('json'); const post = db.query( 'INSERT INTO posts (id, title, content) VALUES (?, ?, ?) RETURNING *' ).get(crypto.randomUUID(), data.title, data.content); return c.json({ post }, 201); }) .delete('/posts/:id', (c) => { const post = db.query('DELETE FROM posts WHERE id = ? RETURNING *').get(c.req.param('id')); if (!post) { throw new HTTPException(404, { message: 'Post not found' }); } return c.json({ deleted: true, post }); }); client = testClient(app); }); afterEach(() => { db.close(); }); test('GET /posts returns empty array initially', async () => { const res = await client.posts.$get(); expect(res.status).toBe(200); const data = await res.json(); expect(data.posts).toEqual([]); }); test('POST /posts creates post', async () => { const res = await client.posts.$post({ json: { title: 'Test Post', content: 'This is a test post', } }); expect(res.status).toBe(201); const data = await res.json(); expect(data.post).toMatchObject({ title: 'Test Post', content: 'This is a test post', }); expect(data.post.id).toBeTruthy(); expect(data.post.created_at).toBeTruthy(); }); test('POST /posts validates input', async () => { const res = await client.posts.$post({ json: { title: '' } as any // Invalid input }); expect(res.status).toBe(400); const error = await res.json(); expect(error).toHaveProperty('error'); }); test('GET /posts/:id returns post', async () => { // Create post first const createRes = await client.posts.$post({ json: { title: 'Test', content: 'Content' } }); const { post } = await createRes.json(); // Get post const res = await client.posts[':id'].$get({ param: { id: post.id } }); expect(res.status).toBe(200); const data = await res.json(); expect(data.post).toEqual(post); }); test('GET /posts/:id returns 404 for non-existent post', async () => { const res = await client.posts[':id'].$get({ param: { id: 'non-existent' } }); expect(res.status).toBe(404); const error = await res.json(); expect(error.error).toBe('Post not found'); }); test('DELETE /posts/:id deletes post', async () => { // Create post const createRes = await client.posts.$post({ json: { title: 'Test', content: 'Content' } }); const { post } = await createRes.json(); // Delete post const deleteRes = await client.posts[':id'].$delete({ param: { id: post.id } }); expect(deleteRes.status).toBe(200); const deleteData = await deleteRes.json(); expect(deleteData.deleted).toBe(true); // Verify deletion const getRes = await client.posts[':id'].$get({ param: { id: post.id } }); expect(getRes.status).toBe(404); }); test('GET /posts returns created posts', async () => { // Create multiple posts await client.posts.$post({ json: { title: 'Post 1', content: 'Content 1' } }); await client.posts.$post({ json: { title: 'Post 2', content: 'Content 2' } }); await client.posts.$post({ json: { title: 'Post 3', content: 'Content 3' } }); const res = await client.posts.$get(); const data = await res.json(); expect(data.posts).toHaveLength(3); expect(data.posts.map((p: any) => p.title)).toEqual(['Post 1', 'Post 2', 'Post 3']); }); }); ``` ## Testing Authentication ```typescript import { createFactory } from 'hono/factory'; import { HTTPException } from 'hono/http-exception'; type Env = { Variables: { user: { id: string; role: 'admin' | 'user' }; }; }; const factory = createFactory(); // Mock token verification const mockUsers = new Map([ ['valid-token', { id: 'user-123', role: 'user' as const }], ['admin-token', { id: 'admin-456', role: 'admin' as const }], ]); const authMiddleware = factory.createMiddleware(async (c, next) => { const token = c.req.header('authorization')?.replace('Bearer ', ''); if (!token) { throw new HTTPException(401, { message: 'Missing token' }); } const user = mockUsers.get(token); if (!user) { throw new HTTPException(401, { message: 'Invalid token' }); } c.set('user', user); await next(); }); const requireAdmin = factory.createMiddleware(async (c, next) => { const user = c.get('user'); if (user.role !== 'admin') { throw new HTTPException(403, { message: 'Admin access required' }); } await next(); }); describe('Authentication', () => { const app = factory.createApp() .get('/public', (c) => c.json({ public: true })) .use('/protected/*', authMiddleware) .get('/protected/profile', (c) => { const user = c.get('user'); return c.json({ user }); }) .use('/protected/admin/*', requireAdmin) .get('/protected/admin/dashboard', (c) => { return c.json({ admin: true }); }); const client = testClient(app); test('Public route accessible without auth', async () => { const res = await client.public.$get(); expect(res.status).toBe(200); const data = await res.json(); expect(data.public).toBe(true); }); test('Protected route requires auth', async () => { const res = await client.protected.profile.$get(); expect(res.status).toBe(401); const error = await res.json(); expect(error.error).toBe('Missing token'); }); test('Protected route accepts valid token', async () => { const res = await client.protected.profile.$get({}, { headers: { Authorization: 'Bearer valid-token' } }); expect(res.status).toBe(200); const data = await res.json(); expect(data.user).toEqual({ id: 'user-123', role: 'user' }); }); test('Protected route rejects invalid token', async () => { const res = await client.protected.profile.$get({}, { headers: { Authorization: 'Bearer invalid-token' } }); expect(res.status).toBe(401); const error = await res.json(); expect(error.error).toBe('Invalid token'); }); test('Admin route requires admin role', async () => { const res = await client.protected.admin.dashboard.$get({}, { headers: { Authorization: 'Bearer valid-token' } }); expect(res.status).toBe(403); const error = await res.json(); expect(error.error).toBe('Admin access required'); }); test('Admin route accepts admin token', async () => { const res = await client.protected.admin.dashboard.$get({}, { headers: { Authorization: 'Bearer admin-token' } }); expect(res.status).toBe(200); const data = await res.json(); expect(data.admin).toBe(true); }); }); ``` ## Testing Middleware ```typescript import { logger } from 'hono/logger'; describe('Middleware', () => { test('Logger middleware logs requests', async () => { const logs: string[] = []; // Custom logger that captures logs const customLogger = (message: string) => { logs.push(message); }; const app = new Hono() .use('*', logger(customLogger)) .get('/test', (c) => c.json({ ok: true })); const client = testClient(app); await client.test.$get(); expect(logs.length).toBeGreaterThan(0); expect(logs.some(log => log.includes('GET'))).toBe(true); expect(logs.some(log => log.includes('/test'))).toBe(true); }); test('Custom middleware runs before handler', async () => { const executionOrder: string[] = []; const app = new Hono() .use('*', async (c, next) => { executionOrder.push('middleware-before'); await next(); executionOrder.push('middleware-after'); }) .get('/test', (c) => { executionOrder.push('handler'); return c.json({ ok: true }); }); const client = testClient(app); await client.test.$get(); expect(executionOrder).toEqual([ 'middleware-before', 'handler', 'middleware-after', ]); }); test('Middleware can modify context', async () => { type Env = { Variables: { requestId: string; timestamp: number; }; }; const factory = createFactory(); const contextMiddleware = factory.createMiddleware(async (c, next) => { c.set('requestId', crypto.randomUUID()); c.set('timestamp', Date.now()); await next(); }); const app = factory.createApp() .use('*', contextMiddleware) .get('/test', (c) => { return c.json({ requestId: c.get('requestId'), timestamp: c.get('timestamp'), }); }); const client = testClient(app); const res = await client.test.$get(); const data = await res.json(); expect(data.requestId).toBeTruthy(); expect(typeof data.requestId).toBe('string'); expect(data.timestamp).toBeTruthy(); expect(typeof data.timestamp).toBe('number'); }); }); ``` ## Testing Error Handling ```typescript import { HTTPException } from 'hono/http-exception'; import { ZodError } from 'zod'; describe('Error Handling', () => { test('HTTPException returns correct status and message', async () => { const app = new Hono() .get('/error', () => { throw new HTTPException(418, { message: "I'm a teapot" }); }) .onError((err, c) => { if (err instanceof HTTPException) { return c.json({ error: err.message }, err.status); } return c.json({ error: 'Internal error' }, 500); }); const client = testClient(app); const res = await client.error.$get(); expect(res.status).toBe(418); const error = await res.json(); expect(error.error).toBe("I'm a teapot"); }); test('Validation errors handled correctly', async () => { const Schema = z.object({ email: z.string().email(), age: z.number().int().positive(), }); const app = new Hono() .post('/validate', zValidator('json', Schema), (c) => { const data = c.req.valid('json'); return c.json({ data }); }) .onError((err, c) => { if (err instanceof ZodError) { return c.json({ error: 'Validation failed', issues: err.issues, }, 400); } return c.json({ error: 'Internal error' }, 500); }); const client = testClient(app); const res = await client.validate.$post({ json: { email: 'invalid', age: -1 } as any }); expect(res.status).toBe(400); const error = await res.json(); expect(error.error).toBe('Validation failed'); expect(error.issues).toBeTruthy(); expect(Array.isArray(error.issues)).toBe(true); }); test('Generic errors sanitized in production', async () => { const originalEnv = process.env.NODE_ENV; const app = new Hono() .get('/error', () => { throw new Error('Sensitive internal error'); }) .onError((err, c) => { const isDev = process.env.NODE_ENV !== 'production'; return c.json({ error: isDev ? err.message : 'Internal server error' }, 500); }); const client = testClient(app); // Development mode process.env.NODE_ENV = 'development'; let res = await client.error.$get(); let error = await res.json(); expect(error.error).toBe('Sensitive internal error'); // Production mode process.env.NODE_ENV = 'production'; res = await client.error.$get(); error = await res.json(); expect(error.error).toBe('Internal server error'); // Restore process.env.NODE_ENV = originalEnv; }); }); ``` ## Integration Testing with Database ```typescript describe('Integration Tests', () => { let db: Database; beforeEach(() => { db = new Database(':memory:'); // Create schema db.run(` CREATE TABLE users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); db.run(` CREATE TABLE posts ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ) `); }); afterEach(() => { db.close(); }); test('Full user and post workflow', async () => { const app = new Hono() .post('/users', zValidator('json', z.object({ email: z.string().email(), name: z.string(), })), (c) => { const data = c.req.valid('json'); const user = db.query( 'INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *' ).get(crypto.randomUUID(), data.email, data.name); return c.json({ user }, 201); }) .get('/users/:id/posts', (c) => { const posts = db.query( 'SELECT * FROM posts WHERE user_id = ?' ).all(c.req.param('id')); return c.json({ posts }); }) .post('/posts', zValidator('json', z.object({ userId: z.string(), title: z.string(), content: z.string(), })), (c) => { const data = c.req.valid('json'); const post = db.query( 'INSERT INTO posts (id, user_id, title, content) VALUES (?, ?, ?, ?) RETURNING *' ).get(crypto.randomUUID(), data.userId, data.title, data.content); return c.json({ post }, 201); }); const client = testClient(app); // Create user const userRes = await client.users.$post({ json: { email: 'alice@example.com', name: 'Alice' } }); expect(userRes.status).toBe(201); const { user } = await userRes.json(); // Create posts for user await client.posts.$post({ json: { userId: user.id, title: 'Post 1', content: 'Content 1' } }); await client.posts.$post({ json: { userId: user.id, title: 'Post 2', content: 'Content 2' } }); // Get user's posts const postsRes = await client.users[':id'].posts.$get({ param: { id: user.id } }); expect(postsRes.status).toBe(200); const { posts } = await postsRes.json(); expect(posts).toHaveLength(2); expect(posts.map((p: any) => p.title)).toEqual(['Post 1', 'Post 2']); }); test('Transaction rollback on error', async () => { const app = new Hono() .post('/bulk-create', zValidator('json', z.object({ users: z.array(z.object({ email: z.string(), name: z.string() })) })), (c) => { const data = c.req.valid('json'); try { db.transaction(() => { for (const user of data.users) { db.run( 'INSERT INTO users (id, email, name) VALUES (?, ?, ?)', [crypto.randomUUID(), user.email, user.name] ); } })(); return c.json({ created: data.users.length }, 201); } catch (err) { throw new HTTPException(400, { message: 'Bulk create failed' }); } }); const client = testClient(app); // Attempt to create users with duplicate email const res = await client['bulk-create'].$post({ json: { users: [ { email: 'alice@example.com', name: 'Alice' }, { email: 'alice@example.com', name: 'Alice Duplicate' }, // Duplicate! ] } }); expect(res.status).toBe(400); // Verify no users were created const count = db.query('SELECT COUNT(*) as count FROM users').get() as { count: number }; expect(count.count).toBe(0); }); }); ``` ## Mocking Patterns ```typescript describe('Mocking', () => { test('Mock external API calls', async () => { // Mock fetch const originalFetch = global.fetch; global.fetch = async (url: string | URL | Request) => { return new Response(JSON.stringify({ mocked: true }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }; const app = new Hono() .get('/proxy', async (c) => { const res = await fetch('https://api.example.com/data'); const data = await res.json(); return c.json({ data }); }); const client = testClient(app); const res = await client.proxy.$get(); const data = await res.json(); expect(data.data.mocked).toBe(true); // Restore global.fetch = originalFetch; }); test('Mock database with interface', async () => { interface IDatabase { query(sql: string): { get(id: string): any }; } class MockDatabase implements IDatabase { private data = new Map([ ['1', { id: '1', name: 'Alice' }], ['2', { id: '2', name: 'Bob' }], ]); query(sql: string) { return { get: (id: string) => this.data.get(id), }; } } const mockDb = new MockDatabase(); const app = new Hono() .get('/users/:id', (c) => { const user = mockDb.query('SELECT * FROM users WHERE id = ?').get(c.req.param('id')); if (!user) { throw new HTTPException(404, { message: 'User not found' }); } return c.json({ user }); }); const client = testClient(app); const res = await client.users[':id'].$get({ param: { id: '1' } }); const data = await res.json(); expect(data.user.name).toBe('Alice'); }); }); ```