# HonoXにVitestを導入する

  ## はじめに

HonoX アプリケーション、特に Cloudflare Workers 上で動作するものは、D1 データベース、R2 バケット、KV ストア、環境変数、シークレットといった Cloudflare の各種サービスと密接に連携します。
これらの連携部分を含めたテストは、アプリケーション全体の動作を保証する上で不可欠です。
本記事は Cloudflare Workers のバインディング(D1)を利用する HonoX アプリケーションのテストを効率的に行うためのガイドとなることを目指します。

## 必要なファイルの準備

テスト環境を構築する前に、プロジェクトに必要な設定ファイルとディレクトリ構造を準備します。
このプロジェクトは以下の HonoX リポジトリにある `blog` 配下のコードに絞って実践していきます。

https://github.com/yusukebe/honox-examples

### プロジェクトのディレクトリ構造 (テスト関連部分)

```bash
├── app/
│   ├── routes/ (HonoXのルーティングファイル)
│   └── ... (他のアプリケーションコード)
├── test/ (テスト用ディレクトリ)
│   ├── env.d.ts (テスト環境用の型定義)
│   ├── vitest.setup.ts (Vitestのセットアップファイル)
│   ├── vitest.config.ts (テスト用のVitest設定)
│   └── routes/ (テスト対象のルートに対応するテストファイル)
│       └── index.test.tsx (例: app/routes/index.tsx のテスト)
├── package.json
├── tsconfig.json
└── wrangler.jsonc (Cloudflare Workersの設定ファイル)
```

### `package.json` への依存関係の追加

プロジェクトの `package.json` に、テストに必要な開発依存関係を追加します。
`vitest` と `@cloudflare/vitest-pool-workers` に限り、公式ドキュメントの `pnpm add -D vitest@~3.1.0 @cloudflare/vitest-pool-workers` に従っています。

```bash
pnpm add -D vitest@~3.1.0 @cloudflare/vitest-pool-workers vite-tsconfig-paths @types/node
```

後続処理で `wrangler` のバージョンが 4 以上である必要があるため、各種パッケージを最新化していきます。
```bash
pnpm update --latest
```
### `wrangler.jsonc` の設定確認

テスト対象の Cloudflare Workers のバインディング (D1 データベース、R2 バケット、環境変数など) が `wrangler.jsonc` に正しく定義されていることを確認します。
`@cloudflare/vitest-pool-workers` はこのファイルを読み込み、テスト環境にバインディングを提供します。

```json
{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "honox-examples-blog",
  "main": "./dist/index.js",
  "compatibility_date": "2024-04-01",
  "compatibility_flags": ["nodejs_compat"],
  "assets": {
    "directory": "./dist"
  },
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "hono-blog-demo-local",
      "database_id": "7d4931d8-eb0d-41cb-ba71-a4d01506828a"
    }
  ]
}
```

### TypeScriptの設定 (`tsconfig.json`)

プロジェクトルートの `tsconfig.json` に、`@cloudflare/workers-types` やテストで使うパスエイリアス (`@/*`) の設定が含まれていることを確認します。

```json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "skipLibCheck": true,
    "lib": ["ESNext", "DOM"],
    "types": ["vite/client", "@cloudflare/workers-types/2023-07-01"],
    "jsx": "react-jsx",
    "jsxImportSource": "hono/jsx",
    "baseUrl": ".",
    "paths": {
      "@/*": ["app/*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"]
}

```

### `worker-configuration.d.ts` の生成


プロジェクトルートに Cloudflare Workers のバインディングに対応した型定義ファイル `worker-configuration.d.ts` を `pnpm wrangler types` コマンドで生成します。
```bash
pnpm wrangler types
```
これはテストコードで `c.env.DB` などの型推論を可能にします。
作成されたファイルはこのように型推論をするために必要な内容が多数生成されます。
```ts
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 751a7ef0204e37564547937fa13c0dba)
// Runtime types generated with workerd@1.20250508.0 2024-04-01 nodejs_compat
declare namespace Cloudflare {
  interface Env {
    DB: D1Database;
  }
}
interface Env extends Cloudflare.Env {}

// Begin runtime types
/*! *****************************************************************************
Copyright (c) Cloudflare. All rights reserved.
Copyright (c) Microsoft Corporation. All rights reserved.

Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License. You may obtain a copy of the
License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions
and limitations under the License.
***************************************************************************** */
/* eslint-disable */
// noinspection JSUnusedGlobalSymbols
declare var onmessage: never;
/**
 * An abnormal event (called an exception) which occurs as a result of calling a method or accessing a property of a web API.
 *
 * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
 */
declare class DOMException extends Error {
  constructor(message?: string, name?: string);
  /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) */
  readonly message: string;
  /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) */
  readonly name: string;
  /**
   * @deprecated
   *
   * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
   */
  readonly code: number;
  static readonly INDEX_SIZE_ERR: number;
  static readonly DOMSTRING_SIZE_ERR: number;
  static readonly HIERARCHY_REQUEST_ERR: number;
  static readonly WRONG_DOCUMENT_ERR: number;
  static readonly INVALID_CHARACTER_ERR: number;
  static readonly NO_DATA_ALLOWED_ERR: number;
  static readonly NO_MODIFICATION_ALLOWED_ERR: number;
  static readonly NOT_FOUND_ERR: number;
  static readonly NOT_SUPPORTED_ERR: number;
  static readonly INUSE_ATTRIBUTE_ERR: number;
  static readonly INVALID_STATE_ERR: number;
  static readonly SYNTAX_ERR: number;
  static readonly INVALID_MODIFICATION_ERR: number;
  static readonly NAMESPACE_ERR: number;
  static readonly INVALID_ACCESS_ERR: number;
  static readonly VALIDATION_ERR: number;
  static readonly TYPE_MISMATCH_ERR: number;
  static readonly SECURITY_ERR: number;
  static readonly NETWORK_ERR: number;
  static readonly ABORT_ERR: number;
  static readonly URL_MISMATCH_ERR: number;
  static readonly QUOTA_EXCEEDED_ERR: number;
  static readonly TIMEOUT_ERR: number;
  static readonly INVALID_NODE_TYPE_ERR: number;
  static readonly DATA_CLONE_ERR: number;
  get stack(): any;
  set stack(value: any);
}
// 長すぎるので省略
```

もし `interface Env` しか生成されない場合は `wrangler` を `pnpm update --latest` などで最新化してからコマンドを実行します。
実行時に以下のようなコマンドが表示されていればファイルが生成されるはずです！
```bash
root ➜ /workspaces/honox-examples-sui (feature-vitest-start) $ pnpm wrangler types

 ⛅️ wrangler 4.16.1
-------------------

Generating project types...

declare namespace Cloudflare {
        interface Env {
                DB: D1Database;
        }
}
interface Env extends Cloudflare.Env {}

Generating runtime types...

Runtime types generated.

────────────────────────────────────────────────────────────
✨ Types written to worker-configuration.d.ts

Action required Migrate from @cloudflare/workers-types to generated runtime types
`wrangler types` now generates runtime types and supersedes @cloudflare/workers-types.
You should now uninstall @cloudflare/workers-types and remove it from your tsconfig.json.

📖 Read about runtime types

https://developers.cloudflare.com/workers/languages/typescript/#generate-types

📣 Remember to rerun 'wrangler types' after you change your wrangler.json file.
```
## テスト環境の設定

次に、`test` ディレクトリ内にテスト専用の設定ファイルを作成します。

### テスト用 `tsconfig.json` の作成

`test/tsconfig.json` を作成し、テスト環境固有の TypeScript 設定を行います。

```json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "moduleResolution": "bundler",
    "types": ["vitest/globals", "@cloudflare/vitest-pool-workers"]
  },
  "include": ["./**/*.ts", "./**/*.tsx", "../worker-configuration.d.ts"]
}
```

### テスト用 Vitest設定ファイル (`vitest.config.ts`) の作成

`test/vitest.config.ts` を作成し、`@cloudflare/vitest-pool-workers` を使用するように設定します。
```typescript
import {
  defineWorkersConfig,
  readD1Migrations,
} from '@cloudflare/vitest-pool-workers/config';
import tsconfigPaths from 'vite-tsconfig-paths';
import path from 'node:path';

export default defineWorkersConfig(async () => {
  const migrationsPath = path.resolve(__dirname, '../migrations');
  const migrations = await readD1Migrations(migrationsPath);

  return {
    plugins: [tsconfigPaths()],
    root: __dirname,
    test: {
      globals: true,
      include: ['./**/*.test.{ts,tsx}'],
      setupFiles: ['./vitest.setup.ts'],
      poolOptions: {
        workers: {
          wrangler: {
            configPath: path.resolve(__dirname, '../wrangler.jsonc'),
          },
          miniflare: {
            bindings: {
              MIGRATIONS: migrations,
            },
          },
        },
      },
    },
  };
});

```
`miniflare.bindings.MIGRATIONS` に `readD1Migrations` で読み込んだ D1 マイグレーションデータを設定しています。これにより、セットアップファイルでマイグレーションを適用できます。

### テスト環境用型定義 (`env.d.ts`) の作成

`test/env.d.ts` を作成し、テスト環境で `import { SELF, env } from 'cloudflare:test'` を利用する際の型定義を行います。

```typescript
import type { Env as WorkerEnv } from '../worker-configuration';

declare module 'cloudflare:test' {
  interface ProvidedEnv extends WorkerEnv {
    DB: D1Database;
    MIGRATIONS?: D1Migration[];
  }
}

```
`migrationsPath` は `CREATE文` が記載されている SQL があるフォルダを指定する必要があるので、`blog.sql` を `migrations/` フォルダに移動します。
`WorkerEnv` は `wrangler types` で生成された `worker-configuration.d.ts` からインポートします。
これにより、`wrangler.jsonc` で定義されたバインディングの型がテスト環境でも利用可能になります。

### Vitestセットアップファイル (`vitest.setup.ts`) の作成

`test/vitest.setup.ts` を作成し、各テストの実行前に D1 マイグレーションを適用する処理を記述します。

```typescript
import { env, applyD1Migrations } from 'cloudflare:test';
import { beforeAll } from 'vitest';

beforeAll(async () => {
  if (env.DB && env.MIGRATIONS) {
    await applyD1Migrations(env.DB, env.MIGRATIONS);
  }
});
```


## 最初のテストの作成

設定が完了したら、テストファイルを作成していきましょう。
例として、`app/routes/index.tsx` に対するテストを `test/routes/index.test.tsx` に作成します。
HonoX はファイルベースでルーティングが行われているため、各エンドポイントごとにテストを実施できます。

```typescript
import { SELF, env } from 'cloudflare:test';

async function insertTestArticle(article: {
  id: string;
  title: string;
  content: string;
  created_at?: string;
}) {
  const createdAt = article.created_at || new Date().toISOString();
  await env.DB.prepare(
    'INSERT INTO articles (id, title, content, created_at) VALUES (?, ?, ?, ?)',
  )
    .bind(article.id, article.title, article.content, createdAt)
    .run();
}

describe('GET / (app/routes/index.tsx)', () => {
  afterEach(async () => {
    vi.restoreAllMocks();
    await env.DB.exec('DELETE FROM articles;');
  });

  it('記事一覧が正常に表示されること', async () => {
    await insertTestArticle({
      id: '1',
      title: 'テスト記事1',
      content: 'これはテスト記事の内容です。',
      created_at: '2024-01-01T10:00:00.000Z',
    });
    await insertTestArticle({
      id: '2',
      title: 'テスト記事2',
      content: 'これは2つ目のテスト記事です。',
      created_at: '2024-01-02T10:00:00.000Z',
    });

    const response = await SELF.fetch('http://localhost/');
    expect(response.status).toBe(200);

    const text = await response.text();
    expect(text).toContain('Hono Blog');
    expect(text).toContain('Posts');
    expect(text).toContain('テスト記事1');
    expect(text).toContain('テスト記事2');
    expect(text).toContain('Create Post');
  });

  it('記事が存在しない場合でも正常にページが表示されること', async () => {
    const response = await SELF.fetch('http://localhost/');
    expect(response.status).toBe(200);

    const text = await response.text();
    expect(text).toContain('Hono Blog');
    expect(text).toContain('Posts');
    expect(text).toContain('Create Post');
  });

  it('記事のリンクが正しく生成されること', async () => {
    await insertTestArticle({
      id: 'test-article-id',
      title: 'テストリンク記事',
      content: 'リンクテスト用の記事です。',
    });

    const response = await SELF.fetch('http://localhost/');
    const text = await response.text();

    expect(text).toContain('articles/test-article-id');
    expect(text).toContain('テストリンク記事');
  });

  it('記事が作成日時の降順で表示されること', async () => {
    await insertTestArticle({
      id: '1',
      title: '古い記事',
      content: '古い記事の内容',
      created_at: '2024-01-01T10:00:00.000Z',
    });
    await insertTestArticle({
      id: '2',
      title: '新しい記事',
      content: '新しい記事の内容',
      created_at: '2024-01-03T10:00:00.000Z',
    });
    await insertTestArticle({
      id: '3',
      title: '中間の記事',
      content: '中間の記事の内容',
      created_at: '2024-01-02T10:00:00.000Z',
    });

    const response = await SELF.fetch('http://localhost/');
    const text = await response.text();

    const newArticleIndex = text.indexOf('新しい記事');
    const middleArticleIndex = text.indexOf('中間の記事');
    const oldArticleIndex = text.indexOf('古い記事');

    expect(newArticleIndex).toBeLessThan(middleArticleIndex);
    expect(middleArticleIndex).toBeLessThan(oldArticleIndex);
  });

  it('データベースエラー発生時に適切にエラーハンドリングされること', async () => {
    const mockError = new Error('データベース接続エラー');
    const prepareSpy = vi.spyOn(env.DB, 'prepare');

    const mockStatement = {
      all: vi.fn().mockRejectedValue(mockError),
    };
    prepareSpy.mockImplementation(() => mockStatement as any);

    const response = await SELF.fetch('http://localhost/');

    expect(response.status).toBeGreaterThanOrEqual(500);
  });
});

```

## テストの実行

`package.json` にテスト実行用のスクリプトを定義してテストを実施していきましょう。
```json
{
  "scripts": {
    "test": "vitest run --config ./test/vitest.config.ts"
    // 他のスクリプト
  }
}
```

記載後は以下のコマンドでテストを実行します。
```bash
pnpm test
```

設定に問題がなければ無事テスト完了です！
```bash
root ➜ /workspaces/honox-examples-sui (feature-vitest-start) $ pnpm run test

> hono-x-examples@ test /workspaces/honox-examples-sui
> vitest run --config ./test/vitest.config.ts


 RUN  v3.1.4 /workspaces/honox-examples-sui/test

[vpw:inf] Starting isolated runtimes for test/vitest.config.ts...
stdout | routes/index.test.tsx
GET   /
POST  /articles/create
GET   /articles/create
POST  /articles/preview
GET   /articles/:id
GET   /*

stdout | routes/index.test.tsx > GET / (app/routes/index.tsx) > データベースエラー発生時に適切にエラーハンドリングされること
データベース接続エラー

 ✓ routes/index.test.tsx (5 tests) 261ms
   ✓ GET / (app/routes/index.tsx) > 記事一覧が正常に表示されること 53ms
   ✓ GET / (app/routes/index.tsx) > 記事が存在しない場合でも正常にページが表示されること 36ms
   ✓ GET / (app/routes/index.tsx) > 記事のリンクが正しく生成されること 42ms
   ✓ GET / (app/routes/index.tsx) > 記事が作成日時の降順で表示されること 54ms
   ✓ GET / (app/routes/index.tsx) > データベースエラー発生時に適切にエラーハンドリングされること 26ms

 Test Files  1 passed (1)
      Tests  5 passed (5)
   Start at  14:40:39
   Duration  1.12s (transform 162ms, setup 268ms, collect 12ms, tests 261ms, environment 1ms, prepare 138ms)

[vpw:dbg] Shutting down runtimes...
root ➜ /workspaces/honox-examples-sui (feature-vitest-start) $ 
```

## まとめ

本記事では、HonoX アプリケーションに Vitest を導入し、Cloudflare Workers のバインディングを含む統合テスト環境を構築する手順を説明しました。

HonoX は現在α版ですが、Cloudflare 製品としての安定性と Hono の設計思想を受け継いでいるため、テストコードの記述が非常に直感的です。
ファイルベースルーティングにより各エンドポイントに対応するテストファイルを作成でき、テストの構造が明確になります。
MPA フレームワークとしてサーバーサイド中心の設計を採用しているため、複雑なクライアントサイドロジックを避けることでテストも簡潔に記述できます。
今後も HonoX の成熟とともに、Cloudflare Workers 上での Web アプリケーション開発がさらに発展することを期待したいですね！

今回使用したリポジトリはこちらです。

https://github.com/Suntory-N-Water/honox-examples-sui/tree/feature-vitest-start

## 参考

https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples

https://developers.cloudflare.com/workers/testing/vitest-integration/write-your-first-test/
    