Integration tests
Test the HTTP contract
Send real Request objects through the complete Hono routing and middleware stack and assert the public response contract.
Unit tests told us normalizeBook() works. They said nothing about whether the route calls it, parses the body first, or turns the result into the right response. That’s what an integration test at the HTTP boundary is for.
The idea: build the app with injected dependencies, send it a real Request, and assert on the response a client would see.
Send a request through the app
Our Books API is built with Hono. Hono apps have an app.request() method that runs a request through the full stack without opening a port:
test('creates a book through the public API', async () => {
const app = makeApp({ repository: new MemoryBooks() })
const response = await app.request('/books', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Dune', author: 'Frank Herbert' })
})
assert.equal(response.status, 201)
assert.match(response.headers.get('content-type'), /^application\/json/)
assert.deepEqual(await response.json(), {
id: 'book-1',
title: 'Dune',
author: 'Frank Herbert'
})
})
makeApp() is the same factory production uses, with an in-memory repository swapped in. The ID is book-1 because the memory repository counts up. Deterministic, as we saw earlier.
Why not call the handler directly?
Because the handler isn’t what production runs. Production runs route matching, body parsing, middleware in a specific order, error translation, and serialization. A direct handler test can pass while the real app rejects the content type, or an auth middleware blocks the request before the handler ever runs.
If authentication and error middleware are part of the production stack, include them here. That’s the point of the test.
Assert the contract, not the internals
Status, the headers that matter, and the shape of the body. That’s the public contract. The name of a repository method is not, and asserting on it ties the test to a refactor.
For errors, assert a stable machine-readable code alongside the status:
assert.equal(response.status, 400)
assert.equal((await response.json()).code, 'validation-failed')
Error prose changes all the time. Clients shouldn’t break when you fix a typo in a message, and neither should tests.
What this test can’t prove
The in-memory repository proves nothing about SQL, migrations, or driver behavior. Keep this test focused on the application boundary. The storage boundary gets its own tests in the next lessons.
Try this: write tests for create then read, invalid JSON, an unsupported content type, an unknown route, and a repository that throws. For each failure, write down which layer should produce the response, and check the request actually crosses that layer.
Lesson completed