Unit tests

Extract pure domain logic

Move validation and transformation rules into small functions whose outputs depend only on explicit inputs.

The cheapest code to test is a pure function: same input, same output, nothing else touched. No server, no database, no clock, no network, no cleanup. You call it and check the result.

Most apps have a lot of this logic hiding inside route handlers. Pull it out and it becomes trivial to test.

Move the rules out of the handler

In our Books API, the POST /books handler trims the fields, checks them, and returns a 400 when something is wrong. Let’s take the checking part out into its own function:

export function normalizeBook(input) {
  const title = input.title?.trim() ?? ''
  const author = input.author?.trim() ?? ''
  const year = input.year === '' || input.year == null
    ? null
    : Number(input.year)

  if (!title) return { ok: false, field: 'title', reason: 'required' }
  if (!author) return { ok: false, field: 'author', reason: 'required' }
  if (year !== null && !Number.isInteger(year)) {
    return { ok: false, field: 'year', reason: 'integer' }
  }

  return { ok: true, value: { title, author, year } }
}

It returns a result object instead of throwing or writing to the response. A test for it is one line:

assert.deepEqual(normalizeBook({ title: ' Dune ', author: 'Frank Herbert' }), {
  ok: true,
  value: { title: 'Dune', author: 'Frank Herbert', year: null }
})

Two decisions, two places

normalizeBook() owns a domain decision: what counts as a valid book. The route handler owns a transport decision: turning { ok: false } into a 400 JSON response.

Keeping them apart means the unit test never builds a Request object. And the domain function never learns that Hono exists. Swap the framework tomorrow and the validation tests don’t change.

Don’t overdo it

Purity is a tool, not a goal. Don’t extract a one-line wrapper just so you can mock it. Extract when explicit inputs and outputs make an important rule easier to see and test.

And remember what the extracted function can’t prove. A perfectly tested normalizeBook() says nothing about whether the handler calls it, or whether it turns the result into the right response. The route still needs an integration test. We’ll write one in the integration module.

Try this: write normalizeBook() with tests for trimming, required fields, a length limit on the title, and the optional year. Add one test that proves the input object is not mutated (compare it before and after). Then add one HTTP test that proves a validation failure becomes the documented 400 response.

Lesson completed