レベル3: ビルド出力検証
ビルドされたファイル、SSG出力、テンプレート、バンドラ設定のテスト。
レベル3がテストするもの
レベル3のテストはビルド出力を検証します。ソースコードを直接テストする代わりに、ビルドを実行してその結果を検査します。
典型的な対象:
- SSG(静的サイト生成)出力HTML
- テンプレートのレンダリング結果
- バンドラ出力(正しいチャンク、コード分割)
- 生成された設定ファイル
- ビルド時のデータ変換(MDXコンパイル、コンテンツコレクション)
ツール
| ツール | 役割 |
|---|---|
| vitest | テストランナー、ファイルの読み取りとアサーション |
| fs/path | ビルド出力を読むためのNode.jsファイルシステムAPI |
| cheerio | HTML出力のパースとクエリ |
例: 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スタブです。
- 合成した内側のバンドルを
mkdtempで作ったスクラッチディレクトリに書き出す。 - 出力ステップ(
emitWorker())を実行して、実際の_worker.jsを生成する。 - 出力されたファイルを
import(pathToFileURL(...))し、default.fetch(new Request(...), envStub)を呼び出す。 (env, ctx)が正しく引き渡されていることをアサートする。ビルドが出力するラッパーは、envをユーザーのハンドラへ渡さなければなりません。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を使用 |