レベル3: ビルド出力検証
ビルドされたファイル、SSG出力、テンプレート、バンドラ設定のテスト。
レベル3がテストするもの
レベル3のテストはビルド出力を検証します。ソースコードを直接テストする代わりに、ビルドを実行してその結果を検査します。
典型的な対象:
SSG(静的サイト生成)出力HTML
テンプレートのレンダリング結果
バンドラ出力(正しいチャンク、コード分割)
生成された設定ファイル
ビルド時のデータ変換(MDXコンパイル、コンテンツコレクション)
ツール
| ツール | 役割 |
|---|---|
| vitest | テストランナー、ファイルの読み取りとアサーション |
| fs/path | ビルド出力を読むためのNode.jsファイルシステムAPI |
| cheerio | HTML出力のパースとクエリ |
ビルドを先に実行する
レベル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を使う)ようにしてください。
書き手が複数いるビルド成果物は共有された可変状態
上のWarningは書き手が1つだけであることを前提にしています。ビルドして、それから読む、という前提です。単独でpnpm build && vitest runを回している限りこれは成り立ちますが、スイートが読んでいる最中にdist/へ書き込めるものが他にあった瞬間、成り立たなくなります。見落とされる書き手はパッケージのライフサイクルフックです。誰もそれをタイプしないからです -- 読み取り専用に見えるコマンドの副作用として発火します。
ビルド成果物は、共有された可変変数と同じように扱ってください。すなわち、信頼する前にすべての書き手を洗い出すことです。
書き手をすべて洗い出す
「1回ビルドして何度も読む」という構成に頼る前に、その成果物へ書き込みうるプロセスをすべて列挙します:
ライフサイクルフック --
prepare、prepack、prepublishOnly、postinstall。これらを実行するのは人間ではなく、パッケージマネージャです。ウォッチャー --
tsup --watch、tsc --watch。開発サーバーが生きている間ずっと書き込み続けます。強制リビルドするスクリプト -- テストレーン、ワークスペースビルド、ローカルの重量レーンなど、呼ばれるたびに無条件でリビルドするもの。
publishの経路 --
npm pack、npm publish、およびそれらがあなたの代わりに実行するもの。
実在するpnpmモノレポでは、packages/<pkg>/dist/に独立した書き手が4つ存在していました -- 上のカテゴリからちょうど1つずつです:
| 書き手 | トリガー |
|---|---|
prepare(tsup + tsc -p tsconfig.build.json) | pnpm installのたびに、無言で |
dev:js(tsup --watch)+ dev:dts(tsc --watch) | pnpm devが生きている間ずっと |
pnpm test / build:workspace / ローカルの重量レーン | 無条件の強制リビルド |
prepack + publish | リリース時 |
これらの重なりを機械的に防ぐ仕組みは何もなく、そのプロジェクトはCLAUDE.mdに書かれた「人間が覚えておくルール」に頼っていました -- pnpm devが動いている間はテストレーンを実行しないこと。これが問題の形です。規律だけが防いでいるレースなのです。その結果、実際に起きたことは:
--watchビルドがdist/を消し去り、.d.tsを一切再生成しなくなった。cleanをwatchモードのときだけ無効にすること(clean: !options.watch)で修正されました。.d.tsのウォッチャーが死んだまま放置され、JSは更新され続けるのにdist/だけが凍結された。その結果スイートは -- そのパッケージ自身の言葉を借りれば -- 大きく失敗する代わりに、古い型に対して静かに型チェックを通してしまいます。ソースともはや一致しない成果物に対するグリーンなスイート。原因はビルドし忘れではなく、並行性です。*. d. ts ワークスペースのビルドガードが、自らのドキュメントの記述どおり、フレッシュネスではなく存在だけを確認していた。設計上、古い成果物を受け入れ、鮮度の判断を人間へ差し戻す作りです。
Warning
存在チェックはキャッシュではありません。 if (!existsSync("dist")) build()が検出できるのは不在であって、陳腐化ではありません。新しい成果物と古い成果物を区別できないガードはスイートを守っていません -- 守っているのは成果物が存在しないケースだけで、それはガードがなくても気づけた失敗です。
--ignore-scriptsはフラグであってプロパティではない
この抽象を具体的にする実際のインシデントがあります。高速ティアのユニットテストがnpmのtarballに含まれるファイル一覧をアサートしており、その呼び出しを読み取り専用にするためにこそ--ignore-scriptsを渡していました:
execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { cwd: PKG_ROOT });ところが、このフラグが実際にprepareを抑止するかどうかは、npmのバージョンに依存していました:
| npm | 同梱される環境 | --ignore-scriptsがあってもprepareを実行するか? |
|---|---|---|
| 10.9.4 | Node 22 -- CIがピン留めしていたバージョン | する(prepackは正しくスキップされる) |
| 11.0.0 | -- | しない |
| 11.9.0 | Node 24 -- ローカル開発で動いていたバージョン | しない |
ローカル開発はNode 24で動いており、packは本当に読み取り専用でした。一方CIはNode 22をピン留めしていたため、同じコマンドがprepareを発火させ、それがclean: trueのtsupを実行し、dist/をまるごと削除しました。ちょうどそのとき、兄弟のspecがパッケージのbin/*.mjsを起動しており、その静的import(.)がERR_MODULE_NOT_FOUNDで失敗したのです。publishの最中に。リリースは3パッケージのうち1つで停止しました。
Warning
テストの読み取り専用性は、挙動がツールのバージョンによって変わるCLIフラグに依存してはなりません。 --ignore-scriptsはフラグであってプロパティではありません。ツールが尊重してくれることを期待する引数であって、ファイルシステムが与えてくれる保証ではないのです。同じ理屈は--dry-runや--no-build、その他あらゆる「意味論が他人のchangelogに書かれているオプトアウト」にも当てはまります。
はみ出したログ行こそが手がかりだった
このリリース障害の数か月前、CIはすでにビルド自身のログ行 -- [copy-theme-packs] dist/theme-packs/ OK -- がpackのJSONより手前に出力され、JSON.parseを壊すのを目撃していました。この文字列はビルドのonSuccessチェーンからしか出力されません。つまり、読み取り専用のはずのコマンドの内側でライフサイクルがすでに走っていた、という直接の証拠だったのです。そのとき適用された修正は、はみ出した行を読み飛ばす寛容なJSONスキャナでした。症状は隠され、リビルドは走り続け、やがてリリースを落としました。
Tip
JSONの手前に出てくるはみ出したログ行は、フォーマットの癖ではありません -- あなたのビルドが喋っているのです。 読み取り専用のコマンドが出力するはずのないものを出力したときは、パーサーを寛容にする前に、それを出力したのが誰なのかを突き止めてください。
ルール: 不変のスナップショットを所有する
ビルド成果物を読むテストは、その不変のスナップショットを所有しなければなりません。 そしてビルドのフレッシュネスキーは、ソースだけでなく成果物自身のバイト列をハッシュしなければなりません。
具体的な形は2つあります。
形1 -- 成果物のスナップショットを取る。 npm packしてtarballを展開するか、{bin,dist}をスクラッチディレクトリにコピーし、テストをそのコピーに向けます。そのあとライフサイクルが実際のdist/に何をしようと、テストが読んでいるのは誰も手を触れられないバイト列です。
// tests/setup/snapshot-package.ts
import { cpSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
const PKG_ROOT = join(__dirname, "../..");
export function snapshotPackage(): string {
// Inside the package root on purpose -- os.tmpdir() breaks bare-specifier
// resolution. Prefix is gitignored and removed in teardown.
const dir = mkdtempSync(join(PKG_ROOT, ".artifact-snapshot-"));
for (const entry of ["bin", "dist", "package.json"]) {
cpSync(join(PKG_ROOT, entry), join(dir, entry), { recursive: true });
}
return dir;
}Warning
解決に関する注意点。 スナップショットの中にベア指定子でimportするものが含まれる場合、スナップショットはos.tmpdir()ではなくパッケージルートの内側に置かなければなりません。Nodeはimport元のファイルからディレクトリツリーを遡ってnode_modulesを解決します。システムの一時ディレクトリから始めた場合、その遡りはプロジェクトに到達しません。そして、いま追いかけているバグとはまったく無関係な理由でERR_MODULE_NOT_FOUNDが出ます。
形2 -- 入力だけでなく出力もハッシュする。 フレッシュネスマーカーでビルドをキャッシュしているなら、ビルド出力そのものをキーに含めてください。ソースだけから導出したキーは、並行する書き手がマーカーの保証対象である成果物を切り詰めたり、書きかけで放置したり、削除したりしても、依然として一致してしまいます。ソースは変わっていないのですから、マーカーは「もはや記述どおりの状態にないdist/」を平然と受け入れるのです。ここで拡張しているフレッシュネスハッシュのレシピはメカニズム2:変更がなければリビルドをスキップするを参照してください。
このガードが正直に主張できること
機械的なガードは役に立ちます。デフォルトレーンのspecを走査し、ビルドやパッケージライフサイクルのコマンド -- npm|pnpm|yarnに続くpack|publish|install|ci|rebuild|run build、裸のtsupなど -- を起動しているものを見つけ、明示的な注釈のないspecを失敗させる、というものです。
そして同じ場所でその限界も明記してください。文字列スキャナが捕捉できるのは起動されたコマンドだけです。シェルのラッパー、スクリプトのエイリアス、dist/配下への直接のrmSync/writeFileSyncは捕捉できません。したがって、機械的に強制されるルールは、それが守ろうとしている不変条件よりも狭いのであり、ドキュメントはどちらがどちらなのかを述べなければなりません。ガードが強制するのは「specはビルドコマンドを起動しない」であり、不変条件は「specは成果物に書き込まない」です。
Danger
過大に主張するガードは、正直な取り決めよりも悪いものです。実際より強く読めてしまうチェックは、まさにこのセクションが述べてきた失敗を再生産します -- 何も強制していない性質に対する確信、というやつです。
例: 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スタブです。
合成した内側のバンドルを
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のランタイムエラー
クライアントサイドのハイドレーション問題(dist の HTML が正しくても、アイランドがハイドレーションするとミスネストしうる -- ハイドレーションのミスネスト変種を参照。これはレベル4のハイドレーション後テストでのみ捕捉できる)
視覚的レンダリングの問題
ブラウザAPIとのインタラクション
ページロード後に動的に読み込まれるコンテンツ
レベル3を使用するタイミング
| シナリオ | レベル3は適切か? |
|---|---|
| SSGページがビルドに含まれていない | はい |
| 出力のHTML構造が誤っている | はい |
| バンドルが大きすぎる/チャンクが不正 | はい |
| ブラウザでのハイドレーションミスマッチ | いいえ -- レベル4を使用 |
| ページがブラウザで空白表示 | いいえ -- レベル4/5を使用 |
| CSSが正しく適用されていない | いいえ -- レベル5を使用 |