Type-safe clients and testing
Test with app.request
Send Request-compatible inputs directly to the app and assert the full HTTP response.
app.request() runs the full stack in memory. Routing, middleware, validation, handlers, and error hooks. No TCP listener required.
The bookmarks suite sends JSON, reads the Response, and checks status, headers, and body:
import { describe, it, expect } from 'vitest'
import { app } from '../src/app.js'
describe('bookmarks API', () => {
it('creates a bookmark', async () => {
const res = await app.request('/bookmarks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: 'Hono docs',
url: 'https://hono.dev'
})
})
expect(res.status).toBe(201)
expect(res.headers.get('location')).toMatch(/^\/bookmarks\//)
expect(await res.json()).toMatchObject({ title: 'Hono docs' })
})
})
When the test passes, Vitest prints:
✓ bookmarks API > creates a bookmark
That proves the same path curl would hit, without starting port 3000:
curl -s -w '\n%{http_code}\n' -X POST http://localhost:3000/bookmarks \
-H 'content-type: application/json' \
-d '{"title":"Hono docs","url":"https://hono.dev"}'
Expect 201, a Location header, and JSON with "title":"Hono docs".
A realistic failure: the app mounts bookmarks at /api/bookmarks but the test calls app.request('/bookmarks'). Vitest shows 404 while curl against the mounted path works. Fix the test path to match app.route('/api', bookmarks) or export the prefix from one constant.
Inject dependencies at app construction:
export function createApp(deps) {
const app = new Hono()
app.use('*', (c, next) => {
c.set('bookmarks', deps.bookmarks)
return next()
})
return app
}
Tests pass a fake store. Production passes the real one. The HTTP path stays identical.
Calling handlers directly with mocked context skips auth middleware. I stopped doing that after a missing auth check shipped because unit tests never mounted it.
app.request() also hits notFound and onError. Send app.request('/nope') and assert 404 JSON. Force a store throw and assert 500 with your safe error shape.
Keep one integration test per route method in the bookmarks API. The suite becomes living documentation for the HTTP contract.
Wrong content type is worth its own case:
curl -s -w '\n%{http_code}\n' -X POST http://localhost:3000/bookmarks \
-H 'content-type: text/plain' -d 'hello'
Expect 400 from the validator or parser, not 500 from an uncaught parse error. Vitest should mirror that request with app.request().
Export createApp(deps) from the app module so tests inject fakes without mutating production singletons.
List routes in the test file comment block so the next reader knows which paths the suite covers without opening app.js.
Try this on your own project: replace one handler unit test with an app.request() test and notice how many mocks disappear.
Lesson completed