playbook/outfitter-agents/plugins/outfitter/skills/hono-dev/examples/testing-patterns.md

739 lines
19 KiB
Markdown

# 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<typeof testClient>;
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<Env>();
// 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<Env>();
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');
});
});
```