Hono入門 - Node.jsで検証付きTodo APIを作ってテストする

中級 | 30分 で読める | 2025.12.02

公式ドキュメント

今回やること

Honoは、Web標準のRequestResponseを中心にAPIを作れる軽量なWebフレームワークです。

この記事ではNode.js上で、メモリにTodoを保存する最小APIを作ります。

  • GET /todosでTodo一覧を返す
  • POST /todosでTodoを1件追加する
  • ZodでPOSTのJSONを検証する
  • app.request()でGET、正常POST、不正POSTをテストする

データベース、認証、ファイルアップロード、Cloudflare Workersなどは扱いません。

runtime adapter上のHonoでRequestがmiddlewareとvalidationを通り、成功時はroute handler、失敗時はhandlerを迂回してResponseになる図

使用環境

2026年7月25日時点で確認したnpmの安定版を使います。

パッケージバージョン
hono4.12.32
@hono/node-server2.0.11
@hono/zod-validator0.9.0
zod4.4.3

Node.jsは現行LTSの24を前提にします。Node.js 26はCurrentであり、まだLTSではありません。

node --version
npm --version

1. プロジェクトを作る

mkdir hono-todo-practice
cd hono-todo-practice
npm init -y
npm pkg set type=module
npm pkg set "scripts.dev=tsx watch src/server.ts"
npm pkg set "scripts.check=tsc"
npm pkg set "scripts.test=vitest run"
npm install hono@4.12.32 @hono/node-server@2.0.11 @hono/zod-validator@0.9.0 zod@4.4.3
npm install --save-dev typescript tsx vitest
mkdir src

Windows PowerShellでsrcを作る場合は、最後の行の代わりに次を実行します。

New-Item -ItemType Directory -Force src

tsconfig.jsonを作ります。

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

2. Todo APIを作る

src/app.tsを作ります。

import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";

type Todo = {
  id: number;
  title: string;
  done: boolean;
};

const newTodoSchema = z.object({
  title: z.string().trim().min(1).max(100),
});

export function createApp() {
  const app = new Hono();
  const todos: Todo[] = [];
  let nextId = 1;

  app.get("/todos", (c) => {
    return c.json({ todos });
  });

  app.post(
    "/todos",
    zValidator("json", newTodoSchema, (result, c) => {
      if (!result.success) {
        return c.json(
          { error: "titleは1〜100文字で入力してください" },
          400,
        );
      }
    }),
    (c) => {
      const input = c.req.valid("json");
      const todo: Todo = {
        id: nextId,
        title: input.title,
        done: false,
      };

      nextId += 1;
      todos.push(todo);
      return c.json(todo, 201);
    },
  );

  return app;
}

createApp()を呼ぶたびに、空の配列とnextIdが新しく作られます。これにより、テスト同士でTodoが混ざりません。

zValidatorは、Content-Type: application/jsonのリクエスト本文をZodで検証します。検証に失敗した場合はルート処理へ進まず、HTTP 400を返します。

3. Node.jsサーバーを起動できるようにする

src/server.tsを作ります。

import { serve } from "@hono/node-server";
import { createApp } from "./app";

const app = createApp();

serve(
  {
    fetch: app.fetch,
    port: 3000,
  },
  (info) => {
    console.log(`Listening on http://localhost:${info.port}`);
  },
);

Honoのアプリを、@hono/node-serverがNode.jsのHTTPサーバーへ接続します。

4. app.requestでテストする

src/app.test.tsを作ります。

import { describe, expect, test } from "vitest";
import { createApp } from "./app";

describe("Todo API", () => {
  test("GET /todos returns an empty list", async () => {
    const app = createApp();
    const response = await app.request("/todos");

    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ todos: [] });
  });

  test("POST /todos creates a todo", async () => {
    const app = createApp();
    const response = await app.request("/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "Honoを試す" }),
    });

    expect(response.status).toBe(201);
    expect(await response.json()).toEqual({
      id: 1,
      title: "Honoを試す",
      done: false,
    });
  });

  test("POST /todos rejects a blank title", async () => {
    const app = createApp();
    const response = await app.request("/todos", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: "   " }),
    });

    expect(response.status).toBe(400);
    expect(await response.json()).toEqual({
      error: "titleは1〜100文字で入力してください",
    });
  });
});

各テストでcreateApp()を呼ぶため、どのテストもTodoが0件の状態から始まります。

JSONを検証する時はContent-Type: application/jsonが必要です。付け忘れるとHonoがJSON本文として読み取れません。

5. 型とテストを確認する

npm run check
npm test

次のように3件すべてが成功すれば、サーバーを起動しなくてもルートと検証を確認できています。

Test Files  1 passed
Tests       3 passed

6. APIを実際に呼ぶ

開発サーバーを起動します。

npm run dev

別のターミナルで一覧を取得します。

curl http://localhost:3000/todos

最初は空です。

{"todos":[]}

Todoを追加します。

curl \
  --request POST \
  --header "Content-Type: application/json" \
  --data '{"title":"Honoを学ぶ"}' \
  http://localhost:3000/todos
{"id":1,"title":"Honoを学ぶ","done":false}

もう一度GETすると、追加したTodoが一覧に含まれます。

curl http://localhost:3000/todos

確認後は、サーバーを起動したターミナルでCtrl+Cを押して停止します。

成功確認

次の4点を確認できれば完了です。

  1. npm run checkが成功する
  2. GET、正常POST、不正POSTの3テストが成功する
  3. curlのPOSTがHTTP 201相当のTodoを返す
  4. その後のGETに追加したTodoが含まれる

Todoはメモリ内の配列だけに保存しています。サーバーを再起動すると消えるため、本番用の永続化ではありません。

よくあるつまずき

Cannot find packageと表示される

記事のプロジェクトフォルダーで依存関係を入れたか確認します。

npm install
npm list hono @hono/node-server @hono/zod-validator zod

POSTが400になる

  • Content-Type: application/jsonを付けたか
  • JSONのプロパティ名がtitle
  • titleが空白だけ、または100文字超になっていないか

EADDRINUSEと表示される

ポート3000を別のプロセスが使っています。以前起動した開発サーバーをCtrl+Cで停止してください。

テスト結果が順番によって変わる

共有されたアプリを全テストで使い回すと、メモリ内Todoが残ります。各テスト内でcreateApp()を呼び、状態を分離してください。

curlの文字列をPowerShellで実行できない

PowerShellではクォートとcurlの扱いが異なります。まずnpm testでAPIを確認するか、Invoke-RestMethodを使ってください。

練習

doneもPOSTで受け取れるようにするのではなく、まずGETのテストを1つ追加してください。

  1. fresh appへ正常なPOSTを1回送る
  2. 同じappへGETを送る
  3. GETの配列に1件あることを検証する

同じシナリオ内では同じapp、別のテストではfresh appを使うのがポイントです。

次のステップ

次は、TodoをIDで取得するGET /todos/:idと404のテストを追加すると、パスパラメータとエラー応答を学べます。

データベース永続化、認証、アップロード、RPC、Cloudflare Workersへのデプロイは、入力検証とテストを保ったまま別の記事で1つずつ追加してください。

参考リソース

← 一覧に戻る
PR
PR
PR
PR