zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

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

ビルド済みサイトの整合性ゲート

ビルド済みサイトに対する決定論的ゲート -- リンクチェック、html-validate、全ページクロールスモーク、404/OGP/画像ヘルスの一括検査 -- ユニットテストやコンポーネントテストが構造的に見られない失敗を捕まえる。

コンテンツ量の多いサイトは、ユニットスイートがグリーンで、コンポーネントスイートもグリーンなまま、それでいて存在しないページへリンクするトップページ、ヒーロー画像が 404 になる記事、削除済み URL を載せたままの sitemap、HTML バリデーションに落ちる一連のページを出荷しうる。これらはどれもロジックのバグではないので、ユニットテストやコンポーネントテストが届く場所には存在しない。これらは ビルド済みサイト全体 の性質 -- ディスク上の実際の dist/、ブラウザが実際に取得するバイト列 -- であり、見られるのはビルドの にビルド済み出力に対して走る決定論的ゲートだけだ。

このレイヤーは、筆者のポートフォリオ全体で最も繰り返し現れながら語られてこなかったテスト層だ。あらゆるドキュメントサイト、マニュアル群、コンテンツプラットフォームがこれを再発明する -- たいていは壊れたリンクが本番に出荷されてから。このページはそれを第一級の層として名前を与え、具体的なゲートをまとめる。

ユニットテストとコンポーネントテストが構造的に見られないもの

ユニットテストは関数を見る。コンポーネントテストは jsdom にレンダリングされた 1 つのコンポーネントを見る。どちらもサイト全体を組み立てることはないので、ある一群の失敗が構造的にどちらからも不可視になる:

  • 内部リンク切れ。 ページ A が /guide/setup へリンクしている。誰かがファイルを /guide/getting-started にリネームする。コンポーネントは全部レンダリングされるが、そのリンクはもう 404 だ。2 つのページを結ぶコンポーネントテストは存在しないので、どれも捕まえられない。

  • 不正な HTML。 コンポーネントが <ul> に迷子の <div> 子要素を出したり、ブロック要素を <p> の中にネストしたりする。jsdom は寛容だが、ビルド済みマークアップに対する本物のバリデータはそうではない。

  • 欠落したページ。 出力されるべきファイルが出力されなかった -- データソースが空を返した、フィルタがエントリを落とした。エラーが出ていないのでビルドはグリーンで、ページはただ dist/ から欠けている。

  • 死んだ画像。 <img src> が、移動されたか、そもそもビルドにコピーされなかったアセットを指している。コンポーネントはタグをレンダリングするが、ブラウザは 404 を受け取る。

  • 陳腐化した sitemap と canonical。 sitemap が削除済み URL を載せたまま、canonical タグがもう存在しないページを指したまま。これらは生成された成果物であり、コンポーネントをテストするものはどれもそこを見ない。

だからこの層は L3/L4 のハイブリッド なのだ。ファイルレベルのチェック -- dist/**/*.html に対するリンク解決と HTML バリデーション -- は Level 3: ビルド出力の検証 だ。出力ファイルを直接読むので、ブラウザは要らない。クロールレベルのチェック -- 配信中のビルドを歩いて各ページが応答することを確認する -- は、たとえフルブラウザではなく素の HTTP フェッチループで実装されても、精神的には Level 4: E2E ブラウザテスト だ。

Note

コンテンツ量の多いサイトでは、支配的な失敗モードはコンポーネントのバグですらない -- それは データの整合性 だ。リンク切れ、死んだ画像、陳腐化した canonical、欠落したページ。あるダウンストリームのコンテンツプラットフォームの監査レーン (zzmod) がこれを直接裏づけた。その canonical/sitemap/link/OGP チェックと 404 一括検査は、コンポーネントテストよりもはるかに多くの実際のリグレッションを捕まえる。以下のゲートが存在するのは、サイトの実際の失敗が、コード単体ではなくビルド済み出力の中に住んでいるからだ。

内部リンクチェック: strict-broken とアローリスト

最初のゲートは、ビルド済み HTML 内のすべての内部リンクを、dist/ に実際に存在するファイルに対して解決する。このリポジトリ自身の pnpm check:links が生きた例だ:

// package.json
"check:links": "node scripts/check-links.js --strict-broken --allowlist=.check-links-allowlist"

これをレポートではなくゲートにしているのは、2 つの設計判断だ:

strict-broken モード。 デフォルトではチェッカーは検出結果を印字して 0 で終了する -- 反復作業中には有用だが、ゲートとしては無意味だ。--strict-broken は内部リンク切れを非ゼロ終了へと切り替え、CI が実際の 404 で本当に失敗するようにする。strict のつまみを 分離可能 に保つこと (broken / absolute-path / trailing-slash) は、デプロイが警告のみのカテゴリにブロックされることなく、本物の 404 でハードに失敗できることを意味する。

既知で修正不能な例外のためのアローリストファイル。 報告されたリンクがすべてリグレッションとは限らない。JA ページが翻訳のない EN 専用の兄弟ページを正当に参照することもあれば、生成ページがソースファイルのないランタイムルートへリンクを出すこともある。アローリストはそうしたケースを <file>:<line>:<href> の行として記録し、完全なレポートが印字された かつ strict モードのカウントの に除外される -- レポートは全体像を示し、ゲートは本物のリグレッションだけを数える。

Warning

アローリストのエントリは負債であって、握りつぶしではない。 各エントリは印字されたレポートに一字一句一致しなければならず、なぜソースで修正できないのかを説明するコメントを伴わなければならない。その例外が例外でなくなった瞬間 -- 誰かが欠けていた JA ミラーを書いた瞬間 -- そのエントリは削除しなければならない。さもなければ strict ゲートは、その同じカテゴリの本物のリグレッションを黙って捕まえなくなる。誰も刈り込まないアローリストは、盲点へと腐っていく。

この組み合わせ -- strict なリンクチェックに html-validate パスを加えたもの -- は複数の兄弟プロジェクトで第一級の CI ゲートになっており (兄弟のドキュメントサイト zudosg は両方をすべての PR で走らせる)、zudo-doc は link-check + html-validate + preview-smoke をまとめて自身の Level 3 ビルド出力ゲート に分類している。

ビルド済み出力に対する HTML バリデーション

2 つ目のファイルレベルのゲートは、出力された HTML に対して html-validate を走らせる。このリポジトリはこう配線している:

// package.json
"check:html": "html-validate \"dist/**/*.html\""

そして、実際にレンダリングを壊すエラーを捕まえる最小限のルールセットを添える:

// .htmlvalidate.json
{
  "rules": {
    "element-permitted-content": "error"
  }
}

element-permitted-content だけで、コンポーネントライブラリが生む最も一般的な構造バグを捕まえる。すなわち、HTML 仕様が禁じる場所にネストされた要素 -- <p> の中のブロック要素、別のインタラクティブ要素の中のインタラクティブ要素、<ul> の直接の子としての <div>。これらは jsdom を通過し、コンポーネントテストの getByRole クエリも通過し、それから実ブラウザで微妙に壊れたレイアウトを生む。ブラウザが不正なツリーを黙って再構成するからだ。ビルド済み マークアップを検証することが、これが表面化する唯一の場所だ。

Tip

ルールセットは小さく始め、どれが重要かを実際のバグが教えてくれるにつれてルールを足していく。初日から最大限のルールセットを使うと、シグナルがスタイル上のノイズ (alt 欠落のヒューリスティック、属性順の好み) に埋もれ、全員がゲートを無視するように仕込まれてしまう。それぞれが実際のリグレッションを 1 つ捕まえてきたエラーレベルのルールが 1 つか 2 つあるほうが、誰も読まない警告 50 個より価値がある。

配信中のビルドに対する全ページクロールスモーク

ファイルレベルのチェックは dist/ を静的に読む。クロールゲートはもう一段先へ行く。ビルド済みサイトを 配信 し (pnpm preview)、すべての URL を歩いて、各ページが応答しレンダリングされることを確認する。これは、サイトが実際に配信されて初めて現れる失敗を捕まえる -- URL を何にもマップしないルーティング設定、ビルドは通るがハイドレーションで投げるページ、リダイレクトループ。

同じ仕事、異なるコストの 2 つの実装:

  • Playwright の crawl-all-pages スペック は、すべてのルートを実ブラウザでナビゲートし、レンダリングされた構造を確認する -- フェッチでは見えないハイドレーションエラーやクライアント側の失敗を捕まえる。マニュアルサイトの zmanuals はまさにこれを crawl-all-pages スペックとして保持している。

  • 高速スモーククローラースクリプト は、すべての URL (sitemap から、あるいはリンクをたどって) に素の HTTP リクエストを投げ、ステータスコードを確認する -- ブラウザがないので、分ではなく秒で走る。zmanuals はこれを Playwright スペックと 並べて 高速レーンとして保持し、商品カタログサイトの zmod は同じ形を all-articlesall-brandsall-categories のクロールとして、コンテンツコレクションごとに 1 つずつ走らせる。

この分割は意図的だ。高速フェッチクローラーは、ビルド済み出力に対して PR ごとに走らせられるほど安く、フルブラウザクロールは、そのコストが正当化される層のために取っておく。

Note

クロールには、dev サーバーではなく本物の配信済みビルドが必要だ。dev サーバーは、本番ビルドが露呈させる失敗を覆い隠しうる (異なるアセット解決、欠落ルートを隠すオンデマンドコンパイル)。pnpm build の出力を pnpm preview で配信したもの -- 実際に出荷される成果物 -- をクロールすれば、スモークテストの判定が本番と一致する。

同じ一族: 404、OGP、画像ヘルス、データの一括検査

リンクチェック、html-validate、クロールスモークはよく知られたメンバーだ。同じ層には、ビルド済み出力に対する決定論的な一括検査のより広い一族が属しており、コンテンツ量の多いサイトではこれらが最も多くを捕まえる:

  • 404 一括検査。 存在 すべき すべての URL が存在すること、そして任意で、既知の不正 URL が正しく 404 になることを確認する。zzmod は専用の 404 一括検査をスケジュールレーンとして保持している。

  • OGP 一括検査。 すべてのページは完全で空でない Open Graph ブロック -- og:titleog:descriptionog:image -- を備えていなければならない。欠けたタグは、そのページが共有されたときにしか、どのテストからも遠く離れた場所で表面化しないからだ。zmodzzmod はどちらも OGP をビルド出力ゲートとして監査する。

  • 画像ヘルス一括検査。 ビルド済み HTML 内のすべての <img src> は、存在し空でないファイルに解決しなければならない。zmod はまさにこれを画像ヘルススクリプトとして走らせる。

  • canonical と sitemap の監査。 sitemap を実際に出力されたページと突き合わせ、すべての canonical タグを存在するページと突き合わせる。zzmod は canonical/sitemap/link/OGP をまとめて監査する。

  • データパイプラインの整合性。 サイトがデータソースから生成される場合、出力に対して データの 不変条件を検証する。zmanualspdf:verify / pdf:validate を走らせて、マニュアルのデータとレンダリングされた PDF の間のページ番号の整合性を確認する -- ビルド出力チェックを装ったデータ整合性チェックだ。

貫く発想はこうだ。これらはすべて どの単一コンポーネントも所有しない、ビルド済み成果物全体についての表明 である。トリガー (完了したビルド) と失敗のシグネチャ (コードはグリーン、サイトは壊れている) を共有するがゆえに、ひとまとまりに属する。

層の割り当て: 何がどこで走るか

すべてのゲートがすべての PR に属するわけではない。T0--T4 の語彙を使い、コストによって各ゲートを 実行層 にマップする:

ゲートいつ
リンクチェック (--strict-broken)T1 (PR ゲート)すべての PR -- 安く、dist/ を静的に読む
html-validate over dist/**T1 (PR ゲート)すべての PR -- 安く、サーバー不要
高速フェッチクロールスモークT1 (PR ゲート)すべての PR -- ビルドを配信するが、なお秒単位
フル Playwright crawl-all-pagesT2 / mainフェッチクロールで足りないとき、あるいはブラウザコストが PR の予算を溢れさせるとき
OGP / 画像ヘルス / 404 一括検査T1 または main安いものは PR ごと、網羅版は main で
外部リンクの腐敗チェックT3 (スケジュール)スケジュールで -- 外部 URL は自分のタイムラインで死ぬので、他人の障害での PR ごとの失敗はノイズ

肝心の区別は 内部か外部か だ。内部のリンク切れは あなたの リグレッションだ。決定論的で、あなたの落ち度で、マージをブロックする PR ゲートに属する。外部リンクが死ぬのは 他人の タイムラインでの 他人の 障害だ。それで PR を失敗させるのは偽陽性なので、死んだリンクのレポートがマージをブロックする代わりに重複排除された issue を起票する スケジュール再試験 の層に属する。外部リンクチェックを PR ゲートに置くのは、この層で最も一般的な間違いだ -- それは人々に赤を無視するよう仕込んでしまう。

最小レシピ: b4push と PR CI に組み込むリンクチェック + html-validate

静的サイトにとってこの層の最小の有用形は、2 つのゲート -- リンクチェックと html-validate -- を、ローカルのプッシュ前パスと PR ゲートの両方に配線することだ。まずスクリプト:

// package.json
"scripts": {
  "build": "<your build command>",
  "check:html": "html-validate \"dist/**/*.html\"",
  "check:links": "node scripts/check-links.js --strict-broken --allowlist=.check-links-allowlist"
}

どちらのゲートもビルドの dist/ に対して走るので、ローカルのプッシュ前スクリプトはそれらを最後に並べる (安いチェックが先、ビルド、それからビルド出力ゲート):

# scripts/run-b4push.sh (excerpt) -- built-output gates run last, after build
pnpm build          # emit dist/
pnpm check:html     # validate the built HTML
pnpm check:links    # resolve every internal link against dist/

同じ 2 つのゲートが必須の PR チェックになる -- ビルドを 1 回、それからその出力に対して両方のチェック:

# .github/workflows/ci.yml (excerpt)
- run: pnpm build
- name: HTML validation
  run: pnpm check:html
- name: Internal link check (strict)
  run: pnpm check:links

Info

ビルドが共有の前提条件だ。どちらのゲートも dist/ を読むので、ビルドを 1 回走らせて両方をその出力に向ける -- チェックごとに再ビルドするのではなく。同じ コマンドを b4push と CI の両方に配線することが、2 つの面が乖離しないようにする -- ローカルゲートと PR ゲートは同一のスクリプトを呼ぶがゆえに同一のものをチェックする。そのパリティは機械的に強制する価値がある。「両方の面に配線されている」ことを検査可能な不変条件として扱うやり方は、スケジュール再試験 のガードマニフェストのパターンを参照。

ここから始め -- すべての PR でリンクチェックと html-validate -- サイトが育ち、実際の失敗がどれが自分の居場所を稼ぐかを教えるにつれて、クロールスモーク、OGP、画像ヘルスの一括検査を足していく。この層の価値は、グリーンなユニットスイートが構造的に盲目になっている、まさにその失敗を、決定論的に、デプロイ前に捕まえることにある。

Revision History

作成更新