InteractiveFrameworks

Testing

Test the cats API without a server: build a fresh app per test, call it with 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 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

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:

  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 { "age": 1 } 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 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 201 bug escapes a test that checks the body and not the status; the DELETE bug escapes a test that stops at 204.
  • POST /cats answers 400 in a test although the body is fine: the content-type header 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 with Unexpected end of JSON input: the response has no body, a 204. Assert the status and stop.

Remember

  • app.request(path, init) runs the app on a Request and gives the Response; no server, no port.
  • A JSON body needs content-type: application/json for 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.