How to Test Hono Apps: Unit and Integration Testing Guide

How to Test Hono Apps: Unit and Integration Testing Guide

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 differencesc.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.

Free tier: 10 tests, 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.

Read more