Files
What you'll learn
- Call a Hono app from a test with app.request(path, init) and read the Response it gives back
- Build a fresh app for every test with a factory and beforeEach, so tests do not depend on each other
- Send a JSON body from a test with the content-type header the validator needs
Testing
The cats API has been graded by requests all along; this lesson makes you the one who writes them. A Hono app is a function from a Request to a Response, which makes it unusually easy to test: there is no server to start, no port to pick, no HTTP client to install. app.request() builds the Request, runs the app and gives back the Response, in the same process as the test. The tests run here on a Jest-style runner inside the browser, and they read exactly as they would under Jest, Vitest or Bun's test runner.
Calling the app
import app from './app';
describe('posts', () => {
test('GET /posts', async () => {
const res = await app.request('/posts');
expect(res.status).toBe(200);
expect(await res.text()).toBe('Many posts');
});
});
app.request(path) sends a GET to that path. Anything else goes in the second argument, the same RequestInit a fetch() call takes:
test('POST /posts', async () => {
const res = await app.request('/posts', {
method: 'POST',
body: JSON.stringify({ message: 'hello hono' }),
headers: new Headers({ 'Content-Type': 'application/json' }),
});
expect(res.status).toBe(201);
expect(res.headers.get('X-Custom')).toBe('Thank you');
expect(await res.json()).toEqual({ message: 'Created' });
});
The Content-Type header is not decoration: the validator from lesson 5 parses a JSON body only when the request says it is JSON, and a test that forgets the header sees the validator receive {} and refuse a perfectly good body. A Request object works too, app.request(new Request('http://localhost/posts', { method: 'POST' })), and a third argument stands in for c.env when the app reads bindings from it.
Everything the response has is there to assert on: res.status, res.headers.get(name), await res.json() or await res.text(). A cookie set by the app is on res.headers.get('set-cookie').
A fresh app per test
The apps in this course keep their cats in an array that lives as long as the module. Tests that share it depend on each other: a test that creates a cat changes what "lists the cats" sees, and the order tests run in decides which pass. The cure is a factory instead of a singleton:
export function createApp() {
const items: Item[] = [];
const app = new Hono();
app.get('/items', (c) => c.json(items));
return app;
}
and beforeEach(() => { app = createApp(); }) in the spec. Every test starts from the same two cats and can do anything to them. The production entry point stays a one-liner, export default createApp().
What a test proves
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, a validator that stops checking the name. A spec that catches all four covers the behaviour it claims to. It is the same idea as the mutation testing this site runs on its own lessons.
A typed client for tests
testClient(app) from hono/testing gives a client typed from the app's routes, await client.posts.$get(), with autocompletion for every path and body. It needs the app to have been built by chaining, as lesson 8 described, and it is what the docs recommend for larger test suites. app.request() is the primitive underneath, and it is what this lesson uses.
Your task
cats.spec.ts has one test. Write the rest, each against a fresh app from beforeEach:
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{ "age": 1 }answers400with{ "error": "name must be a non-empty string" }.DELETE /cats/1answers204, andGET /cats/1is a404afterwards.
The Tests tab shows each test, and under it each planted bug with the tests that caught it.
When it fails
- Every test passes but a bug escapes: the behaviour that bug changes is not asserted. The
201bug escapes a test that checks the body and not the status; theDELETEbug escapes a test that stops at204. POST /catsanswers400in a test although the body is fine: thecontent-typeheader is missing, so the validator saw{}.- A test sees three cats before anything was created: the app is shared between tests. Create it in
beforeEach. await res.json()fails withUnexpected end of JSON input: the response has no body, a204. Assert the status and stop.
Remember
app.request(path, init)runs the app on aRequestand gives theResponse; no server, no port.- A JSON body needs
content-type: application/jsonfor the validator to parse it. - Build a fresh app per test with a factory and
beforeEach. - A test proves something when it fails on the bug it is about; the planted bugs measure that.
Stuck? Show a hint
app.request('/cats/2') gives a Response: check res.status and await res.json(). A POST is app.request('/cats', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ … }) }). 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 accepts a cat without a nameapp.ts