Testing with the clientRPC · Hono

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 will learn

Read the theory for Testing with the client

All RPC lessons

All Hono courses

loading types…

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

Testing with the client

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.