InteractiveFrameworks

Testing with the client

Rewrite the cats API's spec with testClient from hono/testing, the typed client over app.request(), and prove the tests mean something by catching four bugs planted in the code.

What you'll learn

  • Call a Hono app from a test through testClient(app), with paths, bodies and parameters checked by the compiler
  • Narrow a test's response on its status before reading the typed body
  • Measure a spec by the planted bugs it catches, not by the tests it passes

Basics lesson 10 tested the cats API with app.request(): a path, a RequestInit, and a Response to take apart. Every test there wrote the path by hand, built the JSON body and set the content type itself. testClient from hono/testing gives a test the typed client the rest of this course has used, with the app's request already wired in as its fetch, so a test calls client.cats.$post({ json }) and the path, the method, the body and the header come from the type. This lesson rewrites the spec with it, and grades the spec the same way as before: against four bugs planted in the API.

testClient

import { testClient } from 'hono/testing';
import { createApp } from './app';

const client = testClient(createApp());

it('lists the posts', async () => {
  const res = await client.posts.$get();
  expect(res.status).toBe(200);
  expect(await res.json()).toEqual([]);
});

testClient(app) is hc<typeof app>('http://localhost', { fetch: app.request }) and nothing more, and the docs' helpers page says what that implies: "you must define your routes using chained methods directly on the Hono instance", or the client has no type. A second argument stands in for c.env, and a fourth carries client options such as headers, without a fetch, which the helper owns.

What a test gains is the compiler on its side. A body is typed by the validator, so json: { name: 'Milo', age: 1 } is checked before the test runs, and a test cannot send a field the route does not take or leave out one it does. A response is narrowed by its status, so if (res.status === 201) expect(await res.json()).toEqual(…) compares the typed body, and a test of the 404 reads { error } with the type's blessing. A path cannot be mistyped, because client.cat is not a property. The content type header is set for a json body, so the validator's "no header, no body" trap from Basics cannot happen here.

What a test does not gain is coverage. A test that passes proves little on its own; a test that fails when the code is wrong proves something. This lesson plants four bugs in app.ts, one at a time, and runs your spec against each: a 201 that became 200, a lookup that answers the first cat instead of a 404, a DELETE that answers 204 and keeps the cat, and a POST that stores every cat with age 0. A spec that catches all four covers the behaviour it claims to.

A fresh app per test

The factory pattern is unchanged: createApp() returns a new app with its own two cats, so beforeEach(() => { client = testClient(createApp()); }) starts every test from the same state. The type of client is the return type of testClient for that app; the simplest way to write it down is to name the call, const makeClient = () => testClient(createApp()), and declare let client: ReturnType<typeof makeClient>.

Your task

cats.spec.ts has the factory and one test. Write the rest through the client:

  1. GET /cats/2 answers 200 with Luna, and GET /cats/99 answers 404 with { "error": "Cat 99 not found" }.
  2. POST /cats with { name: "Milo", age: 1 } answers 201 with { id: 3, name: "Milo", age: 1 }, and the list has three cats afterwards.
  3. POST /cats with an empty name answers 400 with { "error": "name must be a non-empty string" }.
  4. DELETE /cats/1 answers 204, and GET /cats/1 is a 404 afterwards.

The Tests tab lists each test, and under it each planted bug with the tests that caught it.

When it fails

  • Property 'cat' does not exist on type …: the path is a property chain of the real route; client.cats[':id'], not client.cat.
  • Property 'age' is missing in type '{ name: string; }': the validator types the body as { name, age }; send both.
  • A bug escapes: the behaviour it changes is not asserted. stores every cat with age 0 escapes a test that checks only the status; keeps the cat escapes a test that stops at 204.
  • Unexpected end of JSON input: await res.json() on the 204. Assert the status and move on.

Remember

  • testClient(app) is hc over app.request; it needs chained routes.
  • Bodies, params and paths are checked by the compiler; statuses narrow the response.
  • A fresh app per test, from a factory in beforeEach.
  • A spec is measured by the bugs it catches, not by the tests it passes.
Stuck? Show a hint

client.cats[':id'].$get({ param: { id: '2' } }) gives a Response whose status narrows: if (res.status === 200) expect(await res.json()).toEqual(…). A POST is client.cats.$post({ json: { name, age } }); the header and the body are built for you. A DELETE is proven by the request after it: the cat should be a 404.