zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

レベル3: ビルド出力検証

ビルドされたファイル、SSG出力、テンプレート、バンドラ設定のテスト。

レベル3がテストするもの

レベル3のテストはビルド出力を検証します。ソースコードを直接テストする代わりに、ビルドを実行してその結果を検査します。

典型的な対象:

  • SSG(静的サイト生成)出力HTML

  • テンプレートのレンダリング結果

  • バンドラ出力(正しいチャンク、コード分割)

  • 生成された設定ファイル

  • ビルド時のデータ変換(MDXコンパイル、コンテンツコレクション)

ツール

ツール役割
vitestテストランナー、ファイルの読み取りとアサーション
fs/pathビルド出力を読むためのNode.jsファイルシステムAPI
cheerioHTML出力のパースとクエリ

ビルドを先に実行する

レベル3のテストはdist/配下のファイルを読み取るだけで、自らビルドをトリガーすることはありません。スイートを実行する前にビルドを実行してください。そうしないと、テストは古い(あるいは存在しない)出力を読むことになります:

pnpm build && pnpm vitest run --project build

--project buildは、Vitestの設定で名前付きのbuildプロジェクトを定義していれば、それにスコープした実行になります(Vitestパターンを参照)。名前付きプロジェクトがない場合は、ビルド後に素のvitest runを実行しても同じように機能します。

開発者がビルドステップを覚えていることに頼らないようにするには、globalSetupでVitest自体に組み込み、素のvitest runが常に最新のビルドに対して実行されるようにします:

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

export default defineConfig({
  test: {
    globalSetup: ["./tests/build-setup.ts"],
  },
});
// tests/build-setup.ts
import { execSync } from "node:child_process";

export default function setup() {
  execSync("pnpm build", { stdio: "inherit" });
}

Warning

古いdist/に対してレベル3のスイートがグリーンになっても、それは何も証明しません -- それは昨日の出力に対するアサーションであり、あなたが今変更したコードに対してではありません。結果を信頼する前に、必ず再ビルドする(あるいはglobalSetupを使う)ようにしてください。

例: SSG出力検証

// tests/build-output.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";

const DIST = join(__dirname, "../dist");

describe("build output", () => {
  it("generates index.html with correct title", () => {
    const html = readFileSync(join(DIST, "index.html"), "utf-8");
    expect(html).toContain("<title>My Site</title>");
  });

  it("generates sitemap.xml", () => {
    const sitemap = readFileSync(join(DIST, "sitemap.xml"), "utf-8");
    expect(sitemap).toContain("<urlset");
  });

  it("includes all expected pages", () => {
    const pages = ["index.html", "about/index.html", "docs/index.html"];
    for (const page of pages) {
      const content = readFileSync(join(DIST, page), "utf-8");
      // Case-insensitive: toolchains emit the doctype as lowercase `<!doctype html>`.
      expect(content.toLowerCase()).toContain("<!doctype html>");
    }
  });
});

例: MDXフォーマッターのコントラクトテスト

mdx-formatterの実例パターン:Vitestスイートがフィクスチャファイルで実行して出力を比較することで、Rustフォーマッティングエンジンをテストします。これがレベル1ではなくレベル3である理由は、format()がビルドによって生成されたコンパイル済みのネイティブエンジンを駆動する薄いJSラッパーだからです -- テスト対象はそのビルド成果物であり、手書きのTypeScriptロジックではありません(もしフォーマッターがコンパイル済みバックエンドを持たない純粋なJS/TSであれば、この同じ冪等性チェックはレベル1に属することになります)。

// tests/format.test.ts
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
import { format } from "../src/format";

describe("mdx formatting", () => {
  it("is idempotent", () => {
    const input = readFileSync(join(__dirname, "fixtures/sample.mdx"), "utf-8");
    const first = format(input);
    const second = format(first);
    expect(first).toBe(second);
  });
});

Tip

冪等性テストはフォーマッターやトランスフォーマーにとって強力な不変条件です:操作を2回適用した結果は、1回適用した結果と同じでなければなりません。

出力されたコードを実行する

ここまでの例は、出力を静的なテキストとして比較するものでした。ファイルを読み、その内容に対してアサーションを行います。しかし、ビルドツールの中には実行されるJavaScriptを生成するものがあります。ユーザーのハンドラをラップするアダプタ、バンドラのラッパー、スキャフォルダなどです。これらに対しては、文字列の一致チェックではテンプレートが書き出されたことしか証明できません。生成されたコードが実行時に正しく振る舞うことは証明できないのです。それを検証するには、成果物を書き出し、インポートし、実際に動かす必要があります。

Info

これはレベル3が通常カバーする静的テキストの比較とは別の問いです。後者でのアサーションは「ビルドが正しいファイルを生成したか?」です。一方ここでの問いは「生成されたラッパーは、実行時に入力を内側のハンドラへ確かに引き渡しているか?」というものです。これは*コード生成における引き渡し(threading)*のチェックであり、出力されたモジュールを実行してはじめて答えられます。

再利用できるレシピは、一時ディレクトリ + 動的インポート + envスタブです。

  1. 合成した内側のバンドルをmkdtempで作ったスクラッチディレクトリに書き出す。

  2. 出力ステップ(emitWorker())を実行して、実際の_worker.jsを生成する。

  3. 出力されたファイルをimport(pathToFileURL(...))し、default.fetch(new Request(...), envStub)を呼び出す。

  4. (env, ctx)が正しく引き渡されていることをアサートする。ビルドが出力するラッパーは、envをユーザーのハンドラへ渡さなければなりません。

  5. afterEachでスクラッチディレクトリをクリーンアップする。

// tests/emit-worker.test.ts
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { emitWorker } from "../src/build.js";

let scratchDirs: string[] = [];

afterEach(async () => {
  for (const d of scratchDirs) {
    await rm(d, { recursive: true, force: true });
  }
  scratchDirs = [];
});

async function scratch(): Promise<string> {
  const d = await mkdtemp(join(tmpdir(), "emit-worker-"));
  scratchDirs.push(d);
  return d;
}

describe("emitWorker threads env/ctx into the inner bundle", () => {
  it("the generated wrapper passes env through to the user handler", async () => {
    const dir = await scratch();

    // A synthetic inner bundle that reads env from the request scope
    // the wrapper sets up (here via AsyncLocalStorage), then echoes it.
    const inputPath = join(dir, "inner.mjs");
    await writeFile(
      inputPath,
      `import { AsyncLocalStorage } from "node:async_hooks";
const STORAGE_KEY = "__adapter_als__";
function getStore() {
  const g = globalThis;
  let als = g[STORAGE_KEY];
  if (!als) { als = new AsyncLocalStorage(); g[STORAGE_KEY] = als; }
  return als.getStore();
}
export default {
  async fetch(request) {
    const store = getStore();
    const env = store?.env ?? {};
    return new Response(
      JSON.stringify({
        token: env.API_KEY ?? null,
        ctxKind: typeof store?.ctx?.waitUntil,
      }),
      { status: 200, headers: { "content-type": "application/json" } },
    );
  },
};
`,
      "utf8",
    );

    // Run the real emit step to produce a real _worker.js.
    const out = await emitWorker({
      inputBundlePath: inputPath,
      outdir: join(dir, "dist"),
    });

    // Import the emitted artifact as ESM and drive it with a
    // synthetic Request + env + ctx -- no wrangler / miniflare needed.
    const worker = (await import(pathToFileURL(out.workerPath).href)) as {
      default: {
        fetch: (
          request: Request,
          env: Record<string, unknown>,
          ctx: { waitUntil: (p: Promise<unknown>) => void },
        ) => Promise<Response>;
      };
    };

    const envStub = { API_KEY: "sk-test-1234" };
    const ctx = { waitUntil: () => {} };
    const request = new Request("https://worker.test/api/foo", { method: "POST" });

    const response = await worker.default.fetch(request, envStub, ctx);
    const body = (await response.json()) as { token: string | null; ctxKind: string };

    // The headline check: env actually reached the inner handler.
    expect(body.token).toBe("sk-test-1234");
    // ctx was threaded too -- its surface matches the runtime shape.
    expect(body.ctxKind).toBe("function");
  });
});

Tip

内側のバンドルは合成かつ自己完結に保ちましょう。ラッパーが使う仕組みの読み取り側(ここではglobalThisにキーを置いたAsyncLocalStorage)をインライン化します。テストしているのは出力されたラッパーの引き渡しであって、実際のユーザーパッケージではないため、それをバンドルする必要はありません。同じパターンで、ラッパーが中身を見ずに扱うあらゆるバインディング(データベースハンドル、シークレット、キューなど)をスタブ化できます。ラッパーはenvをそのまま保存するだけで、そのメンバーを一切検査しないからです。

Warning

それでもこれは完全なランタイムテストではありません。出力されたモジュールをインポートして実行するのはNode上であり、本来のターゲットランタイム(Cloudflare Workers、Deno、エッジのサンドボックス)ではありません。ランタイム固有のグローバル、実際のアセットサーバー、プラットフォーム独自のリクエストの挙動はスコープ外です。それらにはレベル4(あるいは実際のwrangler dev / miniflareの実行)が必要です。このテストが証明できるのは、コード生成が責任を負う契約です。入力を渡し、その入力が引き渡されること、これだけです。

ブラインドスポット

Warning

レベル3のテストはファイルの内容を検証しますが、ランタイム動作は検証しません。以下を検出できません:

  • JavaScriptのランタイムエラー

  • クライアントサイドのハイドレーション問題(dist の HTML が正しくても、アイランドがハイドレーションするとミスネストしうる -- ハイドレーションのミスネスト変種を参照。これはレベル4のハイドレーション後テストでのみ捕捉できる)

  • 視覚的レンダリングの問題

  • ブラウザAPIとのインタラクション

  • ページロード後に動的に読み込まれるコンテンツ

レベル3を使用するタイミング

シナリオレベル3は適切か?
SSGページがビルドに含まれていないはい
出力のHTML構造が誤っているはい
バンドルが大きすぎる/チャンクが不正はい
ブラウザでのハイドレーションミスマッチいいえ -- レベル4を使用
ページがブラウザで空白表示いいえ -- レベル4/5を使用
CSSが正しく適用されていないいいえ -- レベル5を使用

Revision History

作成更新