Files
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:
GET /cats/2answers200with Luna, andGET /cats/99answers404with{ "error": "Cat 99 not found" }.POST /catswith{ name: "Milo", age: 1 }answers201with{ id: 3, name: "Milo", age: 1 }, and the list has three cats afterwards.POST /catswith an empty name answers400with{ "error": "name must be a non-empty string" }.DELETE /cats/1answers204, andGET /cats/1is a404afterwards.
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'], notclient.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 0escapes a test that checks only the status;keeps the catescapes a test that stops at204. Unexpected end of JSON input:await res.json()on the204. Assert the status and move on.
Remember
testClient(app)ishcoverapp.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.
Press Run tests to start the app. Its log appears here.Tests
- cats.spec.tsrun to see its tests
Bugs your tests must catch
Each of these replaces one file with a buggy version and runs your tests again. A bug is caught when at least one test fails against it. Tests that pass whatever the code does are not tests.
- POST /cats answers 200 instead of 201app.ts
- GET /cats/:id answers the first cat when the id does not existapp.ts
- DELETE /cats/:id answers 204 but keeps the catapp.ts
- POST /cats stores every cat with age 0app.ts