zudo-test-wisdom

Type to search...

to open search from anywhere

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

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

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

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

典型的な対象:

  • SSG(静的サイト生成)出力HTML
  • テンプレートのレンダリング結果
  • バンドラ出力(正しいチャンク、コード分割)
  • 生成された設定ファイル
  • ビルド時のデータ変換(MDXコンパイル、コンテンツコレクション)

ツール

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

例: 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");
      expect(content).toContain("<!DOCTYPE html>");
    }
  });
});

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

mdx-formatterの実例パターン:VitestスイートがRustフォーマッティングエンジンをフィクスチャファイルで実行し、出力を比較するコントラクトテスト:

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

describe("mdx formatting", () => {
  it("is idempotent", () => {
    const input = readFixture("sample.mdx");
    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のランタイムエラー
  • クライアントサイドのハイドレーション問題
  • 視覚的レンダリングの問題
  • ブラウザAPIとのインタラクション
  • ページロード後に動的に読み込まれるコンテンツ

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

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

Revision History