zudo-test-wisdom

Type to search...

to open search from anywhere

バックエンド & Node.js テスト

Vitestを使用したサーバーサイドNode.jsコードのテストパターン。

フロントエンド・バックエンド分離の哲学

著者のアプローチ:フロントエンドが得意で、バックエンド実装はAIに委任する。これによりテスト戦略が重要になります — バックエンドのコードを自分で書いていない場合、テストがそれが正しく動作しているかの主要な検証手段となります。

重要な原則は関心の分離がテスト可能なバックエンドを実現するということです。フロントエンドとバックエンドが適切に分離されている場合:

  • 各レイヤーを独立してテストできる
  • フロントエンドテストはMSWライクなモックを使用してバックエンドの可用性から切り離す
  • バックエンドテストはブラウザを必要とせず、実際のまたはエミュレートされたインフラストラクチャに対して実行する
  • 一方のレイヤーの変更が他方のレイヤーのテストを壊さない

この分離は単なるアーキテクチャ上の美点ではなく、AI支援バックエンド開発を実現可能にするものです。AIが実装を書き、テストがそれが実際に動作することを検証します。

Cloudflare Functions と Miniflare

Cloudflare Workers/FunctionsでD1(SQLite)データベースとR2オブジェクトストレージを使用するプロジェクトでは、Miniflareがインテグレーションテスト用のローカルエミュレーションを提供します。

テスト環境のセットアップ

実際のD1とR2バインディングを持つ分離されたテスト環境を起動するヘルパーを作成します:

// test/helpers/test-env.ts
import { Miniflare } from "miniflare";
import { readFileSync } from "fs";

export async function createTestEnv() {
  const mf = new Miniflare({
    modules: true,
    script: "",
    d1Databases: ["DB"],
    r2Buckets: ["BUCKET"],
  });
  const env = await mf.getBindings();

  // Run SQL migrations
  const migration = readFileSync("migrations/0001_init.sql", "utf-8");
  const db = env.DB as D1Database;
  await db.exec(migration);

  return { mf, env, db, bucket: env.BUCKET as R2Bucket };
}

テストデータファクトリ

合理的なデフォルト値を持つファクトリ関数を使用してテストデータを作成します:

// test/helpers/factories.ts
export function createTestProject(overrides: Partial<Project> = {}): Project {
  return {
    id: crypto.randomUUID(),
    name: "Test Project",
    createdAt: new Date().toISOString(),
    ...overrides,
  };
}

フルCRUDライフサイクルテスト

エミュレートされたインフラストラクチャに対して、完全なCRUD(作成・読み取り・更新・削除)サイクルをテストします:

import { describe, it, expect, beforeEach } from "vitest";
import { app } from "../src/index";
import { createTestEnv } from "./helpers/test-env";

describe("Projects API", () => {
  let env: any;
  let mf: any;

  beforeEach(async () => {
    ({ env, mf } = await createTestEnv());
  });

  it("creates and retrieves a project", async () => {
    const createRes = await app.request(
      new Request("http://localhost/api/projects", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name: "My Project" }),
      }),
      {},
      env,
    );
    expect(createRes.status).toBe(201);
    const created = await createRes.json();

    const getRes = await app.request(
      new Request(`http://localhost/api/projects/${created.id}`),
      {},
      env,
    );
    expect(getRes.status).toBe(200);
    const fetched = await getRes.json();
    expect(fetched.name).toBe("My Project");
  });
});

ここでの重要なパターンはapp.request(req, {}, env)です — これはMiniflareが提供するバインディングを使用してHonoアプリを直接呼び出し、HTTPを完全にバイパスします。

別のVitest設定

バックエンドテストはenvironment: 'node'を持つ独自のVitest設定が必要です:

// vitest.config.backend.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["test/backend/**/*.test.ts"],
    testTimeout: 10000,
  },
});

ランタイムではなくバインディングをモックする

上記のMiniflareセクションは、実際のエミュレートされたランタイムを起動します。ストレージのセマンティクスをテストするならそれが正解です。しかし自分のハンドラーロジックをテストするときには、それは過剰です。逆の選択肢は、Cloudflareバインディングの公開インターフェースをインメモリのMapで偽装し、Miniflareをまったく起動しないというものです。

KVNamespaceのようなCloudflareバインディングは、いくつかのメソッドを持つだけのオブジェクトにすぎません。Mapを裏側に置いて手書きし、テスト対象のメソッドだけを実装し、as unknown as KVNamespaceでキャストすれば十分です:

// test/helpers/mock-kv.ts
export function createMockKV(): KVNamespace {
  const store = new Map<string, string>();

  return {
    get(key: string): Promise<string | null> {
      return Promise.resolve(store.get(key) ?? null);
    },
    put(key: string, value: string): Promise<void> {
      store.set(key, value);
      return Promise.resolve();
    },
    delete(key: string): Promise<void> {
      store.delete(key);
      return Promise.resolve();
    },
  } as unknown as KVNamespace;
}

同じ手口はR2BucketMap<string, ArrayBuffer>上のget/put/delete)にも、D1Databaseにも使えます。なかでもD1のケースが最も示唆に富みます。SQLエンジンは不要で、クエリの部分文字列をキーにした小さなディスパッチャがあれば事足ります。

// test/helpers/mock-d1.ts
interface ProductRow {
  id: number;
  name: string;
}

export function createMockD1(rows: ProductRow[]): D1Database {
  function makeStatement(sql: string, params: unknown[] = []) {
    return {
      bind(...args: unknown[]) {
        return makeStatement(sql, args);
      },
      async first() {
        if (/where id = \?/i.test(sql)) {
          return rows.find((r) => r.id === params[0]) ?? null;
        }
        return rows[0] ?? null;
      },
      async all() {
        if (/from products/i.test(sql)) {
          return { results: rows, success: true, meta: {} };
        }
        return { results: [], success: true, meta: {} };
      },
      async run() {
        return { success: true, meta: { changes: 1 } };
      },
    };
  }
  return {
    prepare(sql: string) {
      return makeStatement(sql);
    },
    // a real miniflare D1 would test miniflare's SQLite, not this adapter's threading.
  } as unknown as D1Database;
}

このスタブのコメントこそが核心です(「本物のMiniflareのD1は、このアダプターの受け渡しではなく、MiniflareのSQLiteをテストすることになる」という意味です)。このスタブの上に組んだテストは、ハンドラーがバインディングを正しく受け渡し、正しい行で分岐することをアサートします。SQLiteがWHERE句をどう実行するかについては、何もアサートしませんし、すべきでもありません。ここで本物のSQLをMiniflareに流せば、それはあなたのコードではなくMiniflareのSQLiteエンジンをテストすることになります。

ハンドラーのテストでは、素朴なファクトリからenvを組み立て、アプリを直接呼び出します:

import { describe, it, expect } from "vitest";
import { app } from "../src/index";
import { createMockKV } from "./helpers/mock-kv";

function createEnv(kv: KVNamespace) {
  return { KV: kv } as Env;
}

describe("session handler (Map-stub)", () => {
  it("returns 200 when the session exists", async () => {
    const kv = createMockKV();
    await kv.put("session:abc", JSON.stringify({ userId: 1 }));

    const res = await app.request(
      new Request("http://localhost/api/me", {
        headers: { Cookie: "sid=abc" },
      }),
      {},
      createEnv(kv),
    );

    expect(res.status).toBe(200);
  });
});

💡 Tip

判断基準 — ここがギャップです。 SQLやストレージのセマンティクスをテストするとき(このクエリは正しい行を返すか、このR2マルチパートアップロードは正しく再結合されるか)にはMiniflareを使います。ハンドラーの分岐と受け渡しをテストするとき(このルートは読むべきバインディングを読んでいるか、セッションが無いとき401を返すか)には**Mapスタブ**を使います。要となる原則はこうです — テストすべきはあなたのロジックであって、プラットフォームのストレージエンジンではない。 Mapスタブは高速で、wrangler.tomlを必要とせず、素のNodeで動き、状態を厳密にプリシードできます。

時間バケットロジックのテスト

レートリミッター、スライディングウィンドウ、TTLキャッシュ、JWTの有効期限 — これらはテストにおいて共通の問題を抱えています。その挙動が現在時刻に依存し、しかも境界(カウンターがリセットされる、あるいはトークンが失効する、まさにその瞬間)こそが最も間違えやすい部分だということです。テスト内でその境界を越えるためにスリープするのは遅く、不安定です。代わりにクロックを凍結しましょう。

レシピは常に同じです — 時間を凍結し、凍結されたnowからバケットキーを導出し、ストアをしきい値までプリロードし、境界をアサートする。

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { checkRateLimit } from "../rate-limit";
import { createMockKV } from "./helpers/mock-kv";

describe("checkRateLimit", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-18T12:00:00.000Z"));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("blocks the 60th request in the same minute", async () => {
    const kv = createMockKV();
    const env = { RATE_LIMIT: kv, _kv: kv } as MockEnv;

    // Derive the SAME bucket key the production code computes.
    const now = Date.now();
    const minBucket = Math.floor(now / 60_000);

    // Pre-seed 59 prior requests this minute via the inspectable store.
    env._kv._store.set(`rate:min:testhash:${minBucket}`, "59");

    const result = await checkRateLimit("testhash", env);
    expect(result.allowed).toBe(true);

    env._kv._store.set(`rate:min:testhash:${minBucket}`, "60");
    const blocked = await checkRateLimit("testhash", env);
    expect(blocked.allowed).toBe(false);
    expect(blocked.retryAfter).toBeGreaterThan(0);
    expect(blocked.retryAfter).toBeLessThanOrEqual(60);
  });
});

これを成立させるのは2つの要素です。1つめは、テストがプロダクションコードと同一のMath.floor(now / 60_000)という式でminBucketを計算している点です — クロックが凍結されているため両者は同じバケットに落ち着き、プリシードしたキーがそのままコードの読み取るキーになります。2つめは、モックKVが内部のMap_storeとして意図的に露出させており、テストがputを経由せず直接状態を書き込める点です。

ℹ️ Info

検査可能なモックストアという設計。 プロダクション忠実なモックなら内部のMapを隠すでしょう。しかしテスト用のモックは逆を行うべきです。_store(または_kv._store)をエスケープハッチとして露出させ、テストが状態を直接操作できるようにする — ちょうど59件のエントリをシードする、ハンドラーが書き込んだ内容を検査する、あるいは値を壊して復旧処理をテストする、といった具合に。これがウィンドウカウンター、TTL、スライディングウィンドウのあらゆるテストの背後にある再利用可能なパターンです。ウィンドウが埋まるのを待つのではなく、ストアをしきい値に置いて境界をまたいでアサートするのです。

同じクロック凍結の手法はJWTの有効期限にもそのまま使えます。vi.setSystemTimeDate.now()の返す値を制御するため、既知のexpを持つトークンを発行し、その瞬間の直前では受理され、直後では拒否されることをアサートできます:

it("accepts a token before exp and rejects it after", async () => {
  vi.setSystemTime(new Date("2026-03-18T12:00:00.000Z"));
  const token = await createToken({ sub: "user-1" }, TEST_SECRET, "1h");

  // Still inside the hour: valid.
  vi.setSystemTime(new Date("2026-03-18T12:59:00.000Z"));
  await expect(verifyToken(token, TEST_SECRET)).resolves.toMatchObject({
    sub: "user-1",
  });

  // One second past exp: rejected.
  vi.setSystemTime(new Date("2026-03-18T13:00:01.000Z"));
  await expect(verifyToken(token, TEST_SECRET)).rejects.toThrow();
});

📝 Note

これはJWTテストのうち時間ベースの有効期限の側面だけを扱っています。ハードコードされたシークレットとペイロードに対して固定の署名をアサートする(既知解ベクトル)のは別の関心事であり、ここではなく暗号テストに属します。

HTTP APIテスト

ライブまたはデプロイされたエンドポイント(ステージング、プレビュー、本番)に対してテストするには、直接HTTPリクエストを使用します。

環境ベースのURL切り替え

// test/helpers/api-client.ts
function getBaseUrl(): string {
  if (process.env.TEST_API_URL) {
    return process.env.TEST_API_URL;
  }
  if (process.env.CF_PAGES_URL) {
    return process.env.CF_PAGES_URL;
  }
  return "http://localhost:8787";
}

const BASE_URL = getBaseUrl();

export async function apiGet(path: string) {
  return fetch(`${BASE_URL}${path}`, {
    headers: {
      Authorization: `Bearer ${process.env.TEST_API_TOKEN}`,
    },
  });
}

破壊的テストのガード

データを変更するテストは本番環境ではスキップすべきです:

const isProduction = process.env.TEST_ENV === "production";

describe("Admin API", () => {
  it.skipIf(isProduction)("deletes all test data", async () => {
    const res = await apiGet("/api/admin/reset-test-data");
    expect(res.status).toBe(200);
  });
});

ネットワークタイムアウト

HTTPテストはユニットテストよりも長いタイムアウトが必要です:

// vitest.config.http.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["test/http/**/*.test.ts"],
    testTimeout: 30000,
  },
});

モックを使ったHTTPクライアントテスト

HTTPリクエストを行うコード(APIクライアント、認証フロー)をテストする場合、グローバルレベルでfetchをモックします。

vi.stubGlobalパターン

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

describe("ApiClient", () => {
  const fetchMock = vi.fn();

  beforeEach(() => {
    vi.stubGlobal("fetch", fetchMock);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("sends auth header", async () => {
    fetchMock.mockResolvedValueOnce(
      new Response(JSON.stringify({ ok: true }), { status: 200 }),
    );

    const client = new ApiClient({ token: "test-token" });
    await client.get("/api/data");

    expect(fetchMock).toHaveBeenCalledWith(
      expect.stringContaining("/api/data"),
      expect.objectContaining({
        headers: expect.objectContaining({
          Authorization: "Bearer test-token",
        }),
      }),
    );
  });
});

認証フローテスト

モックレスポンスを連鎖させて、トークンリフレッシュとリトライロジックをテストします:

it("retries with refreshed token on 401", async () => {
  // First call returns 401
  fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }));
  // Token refresh succeeds
  fetchMock.mockResolvedValueOnce(
    new Response(JSON.stringify({ token: "new-token" }), { status: 200 }),
  );
  // Retry with new token succeeds
  fetchMock.mockResolvedValueOnce(
    new Response(JSON.stringify({ data: "success" }), { status: 200 }),
  );

  const client = new ApiClient({ token: "old-token" });
  const result = await client.get("/api/data");

  expect(result.data).toBe("success");
  expect(fetchMock).toHaveBeenCalledTimes(3);
});

ファイルシステムテスト

ファイルの読み書きを行うNode.jsツールでは、分離のために一時ディレクトリを使用します。

一時ディレクトリパターン

import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

describe("FileProcessor", () => {
  let tempDir: string;

  beforeEach(() => {
    tempDir = mkdtempSync(join(tmpdir(), "test-"));
  });

  afterEach(() => {
    rmSync(tempDir, { recursive: true, force: true });
  });

  it("processes and writes output file", () => {
    const inputPath = join(tempDir, "input.txt");
    const outputPath = join(tempDir, "output.txt");
    writeFileSync(inputPath, "hello world");

    processFile(inputPath, outputPath);

    expect(readFileSync(outputPath, "utf-8")).toBe("HELLO WORLD");
  });
});

重要なポイント:

  • mkdtempSyncはテスト実行ごとにユニークなディレクトリを作成する — 衝突がない
  • afterEachのクリーンアップにより、テスト間で残存ファイルがないことを保証する
  • すべてのパスはtempDirからの相対パス — テストが実際のファイルシステムに触れることはない

デプロイ後のスモークテスト(シェルスクリプト)

Cloudflare Workers(またはデプロイ済みのAPI)に対して、シェルベースのスモークテストは依存関係のない高速なAPI全体フローの検証を提供します。実際のエンドポイントを叩き、テストデータを作成し、結果をアサートし、終了時にクリーンアップします。

自動クリーンアップ付きテストスクリプト

重要なパターンはtrap EXITで、スクリプトが途中で失敗してもテストデータを削除します:

#!/usr/bin/env bash
set -euo pipefail

BASE_URL="${1:?Usage: smoke-test.sh <server-url>}"
API="$BASE_URL/api/v1"
PASS=0
FAIL=0

# Track resources for cleanup
_AUTH=""
_VAULT_ID=""

cleanup() {
  if [ -n "$_VAULT_ID" ] && [ -n "$_AUTH" ]; then
    curl -sf -X DELETE -H "$_AUTH" "$API/vaults/$_VAULT_ID" >/dev/null 2>&1 || true
  fi
}
trap cleanup EXIT

# --- assertions ---
assert_eq() {
  local label="$1" actual="$2" expected="$3"
  if [ "$actual" = "$expected" ]; then
    echo "  PASS: $label"
    PASS=$((PASS + 1))  # NOT ((PASS++)) -- see gotcha below
  else
    echo "  FAIL: $label (expected '$expected', got '$actual')"
    FAIL=$((FAIL + 1))
  fi
}

⚠️ Warning

Bashのset -eと算術式の落とし穴: ((PASS++))PASS=0のとき0++を評価し、0(偽)を返すため、set -eがスクリプトを終了させます。代わりにPASS=$((PASS + 1))を使用してください。

デュアルE2E戦略:ローカルファースト、デプロイ後にリモート

同じスモークテストスクリプトがURL引数を受け取るため、2つのモードが可能です:

{
  "scripts": {
    "test": "vitest run",
    "test:e2e": "bash scripts/smoke-test.sh https://my-worker.account.workers.dev",
    "test:e2e:local": "bash scripts/smoke-test-local.sh"
  }
}

開発ワークフロー: まずローカル(高速フィードバック)、デプロイ後にリモート(本番検証)。

ローカルラッパーはwrangler devを起動し、準備完了を待ち、スモークテストを実行し、終了時にクリーンアップします:

#!/usr/bin/env bash
set -euo pipefail
PORT=8799

cd "$(dirname "$0")/.."

# Apply local D1 migration
wrangler d1 execute sync-db --local --file=migrations/0001_init.sql 2>/dev/null

# Write local secrets (wrangler uses .dev.vars, NOT shell env vars)
cat > .dev.vars.e2e <<'VARS'
DEV_MODE=true
JWT_SECRET=test-secret-for-local-e2e
VARS

wrangler dev --port "$PORT" --local --env-file .dev.vars.e2e 2>/dev/null &
DEV_PID=$!

cleanup() {
  kill "$DEV_PID" 2>/dev/null || true
  wait "$DEV_PID" 2>/dev/null || true
  rm -f .dev.vars.e2e
}
trap cleanup EXIT

# Wait for server readiness
for i in $(seq 1 30); do
  curl -sf "http://localhost:$PORT/health" >/dev/null 2>&1 && break
  [ "$i" -eq 30 ] && { echo "Server did not start"; exit 1; }
  sleep 1
done

# Reuse the same smoke test
scripts/smoke-test.sh "http://localhost:$PORT"

💡 Tip

ローカル開発でのWranglerシークレット: Wranglerはシェル環境変数をWorkerバインディングとして読み込みません。.dev.varsファイルまたは--env-fileフラグを使用してください。リモートシークレット(wrangler secret put)からローカル開発に移行する際のよくある落とし穴です。

CI統合:デプロイ後の検証

GitHub Actionsのデプロイ後ステップとしてスモークテストを追加します:

- name: Deploy to Cloudflare Workers
  run: cd workers/sync-server && pnpm run deploy
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

- name: Post-deploy smoke test
  run: cd workers/sync-server && pnpm run test:e2e

これにより、ユニットテストでは捕捉できないデプロイ問題を検出します:シークレットの設定ミス、D1マイグレーションの未適用、R2バケットの権限、Durable Objectバインディングエラーなど。

3段階バックエンドテスト戦略

段階ツールスコープタイミング
ユニットVitest + Miniflare個別ハンドラー、プロセス内プッシュごと(CI)
ローカルE2Ewrangler dev + シェルスクリプトフルHTTPフロー、ローカルD1/R2開発中
リモートE2Ecurl + シェルスクリプトデプロイ済みインフラ、実際のバインディングデプロイ後(CI)

ユニットテストはロジックバグを捕捉。ローカルE2Eはインテグレーションバグを捕捉。リモートE2Eはインフラバグを捕捉。各段階が異なる障害クラスをカバーします。

バックエンドテストの重要な原則

  1. vitest設定でenvironment: 'node'を使用するjsdomではなく。バックエンドコードにDOMは不要です。

  2. フロントエンドとバックエンドテストで別々のvitest設定を使用する。異なる環境、異なるタイムアウト、そして多くの場合異なるセットアップファイルが必要です。

  3. テストデータ用のヘルパーファクトリを使用する。テストデータをインラインでハードコードせず、合理的なデフォルト値とオーバーライドを持つファクトリ関数を使用します。

  4. 設定切り替えに環境変数を使用する。ローカル、プレビュー、本番URLの切り替えにはハードコードではなくprocess.envを使用します。

  5. 破壊的テストをit.skipIf()でガードする。データを削除したり状態をリセットしたりするテストは、本番環境では絶対に実行すべきではありません。

  6. ネットワークテストには長いタイムアウトを設定する。デフォルトの5秒タイムアウトはHTTPインテグレーションテストには短すぎます。30秒以上を使用します。

Revision History