Test and ship

Test handlers with requests

Exercise the Hono application directly with standard Request objects before adding network and database integration tests.

Remember the split from the second lesson, app in one file and serve() in another? This is where it pays off. app.request() takes a URL or a Request, runs it through the router and middleware, and gives back a Response. No port, no socket, no server to start and stop.

That makes route tests fast and boring, which is what I want from tests. Here is the first one, using the test runner built into Node.js:

import test from 'node:test'
import assert from 'node:assert/strict'
import app from './app.js'

test('lists books', async () => {
  const response = await app.request('/books')
  assert.equal(response.status, 200)
  assert.deepEqual(await response.json(), { books: [] })
})

Run it with node --test and you get:

✔ lists books (3.2ms)
ℹ tests 1
ℹ pass 1

If the built-in runner is new to you, I covered it in The Node.js built-in test runner. No extra dependency, and it runs TypeScript files in current Node releases.

Test what a client can see

Notice what the test checks: the status and the parsed body. Those are the things a client can observe. I don’t check that books.find() was called, or that a repository method ran once. When the HTTP result is right, the internals are right enough, and when I refactor them the test keeps passing.

For a create, check the pieces of the contract we wrote down:

test('creates a book', async () => {
  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.ok(response.headers.get('Location')?.startsWith('/books/'))
  const { book } = await response.json()
  assert.equal(book.title, 'Dune')
})

Status, Location header, media type, body. That’s the whole checklist, and it’s the same list we put in OpenAPI.

Fresh state for every test

The first example expects { books: [] }. That’s only true if nothing else added a book first. Tests that share an app instance leak state into each other and fail depending on order.

The fix is a factory. Instead of exporting a ready app, export createApp(repository) and build a new one in each test with an empty in-memory repository. The next lesson swaps that repository for a real SQLite file, and the tests don’t change.

Two smaller rules

A Response body is a stream. You read it once. Calling response.json() twice throws, so store the result when you need it more than once.

And test the failures as much as the successes. Write a table of invalid bodies, loop over it, and assert each gets a 422 with application/problem+json. Then make the repository throw and confirm the app answers a clean 500 problem, not an unhandled rejection that kills the test process.

What this layer proves

These tests cover routing, middleware, validation and serialization. They don’t prove the Node adapter listens on the right port, and they don’t prove the SQL is right. Each test layer makes one claim, and the next lesson adds the database claim.

Write tests for list, create, invalid input, missing book and delete now, each with its own fresh app. For the bigger picture on test strategy, the Testing course continues from here.

Lesson completed