ポートフォリオサイトのロゴ
Icon for HonoXにVitestを導入する

HonoXにVitestを導入する

HonoXアプリケーションにVitestを導入する方法を解説します。Cloudflare WorkersのバインディングD1を利用するHonoXアプリケーションのテスト環境構築ガイドです。

はじめに

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

必要なファイルの準備

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

github.com

GitHub - yusukebe/honox-examples: HonoX examples

HonoX examples. Contribute to yusukebe/honox-examples development by creating an account on GitHub.

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

├── 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 に従っています。

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

後続処理で wrangler のバージョンが 4 以上である必要があるため、各種パッケージを最新化していきます。

pnpm update --latest

wrangler.jsonc の設定確認

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

{
  "$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 やテストで使うパスエイリアス (@/*) の設定が含まれていることを確認します。

{
  "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.tspnpm wrangler types コマンドで生成します。

pnpm wrangler types

これはテストコードで c.env.DB などの型推論を可能にします。
作成されたファイルはこのように型推論をするために必要な内容が多数生成されます。

/* 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 しか生成されない場合は wranglerpnpm update --latest などで最新化してからコマンドを実行します。
実行時に以下のようなコマンドが表示されていればファイルが生成されるはずです!

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 設定を行います。

{
  "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 を使用するように設定します。

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.MIGRATIONSreadD1Migrations で読み込んだ D1 マイグレーションデータを設定しています。これにより、セットアップファイルでマイグレーションを適用できます。

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

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

import type { Env as WorkerEnv } from '../worker-configuration';
 
declare module 'cloudflare:test' {
  interface ProvidedEnv extends WorkerEnv {
    DB: D1Database;
    MIGRATIONS?: D1Migration[];
  }
}
 

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

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

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

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 はファイルベースでルーティングが行われているため、各エンドポイントごとにテストを実施できます。

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 にテスト実行用のスクリプトを定義してテストを実施していきましょう。

{
  "scripts": {
    "test": "vitest run --config ./test/vitest.config.ts"
    // 他のスクリプト
  }
}

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

pnpm test

設定に問題がなければ無事テスト完了です!

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 アプリケーション開発がさらに発展することを期待したいですね!

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

github.com

GitHub - Suntory-Y-Water/honox-examples-sui at feature-vitest-start

HonoX examples. Contribute to Suntory-Y-Water/honox-examples-sui development by creating an account on GitHub.

参考

github.com

workers-sdk/fixtures/vitest-pool-workers-examples at main · cloudflare/workers-sdk

⛅️ Home to Wrangler, the CLI for Cloudflare Workers® - cloudflare/workers-sdk

developers.cloudflare.com

Write your first test

Write tests against Workers using Vitest