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

19 KiB

Testing Patterns

Type-safe testing with testClient — no HTTP server required.

Basic Setup

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

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

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

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

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

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

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');
  });
});