zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

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

定期再試験 & 夜間試験

重くプラットフォーム依存なテストレーンをスケジュール実行で運用する -- CI再試験ワークフロー、重複排除されたIssue起票、オンデマンドディスパッチ、ローカル夜間試験。

ローカル専用の重いレーンがセーフティネットにならない理由

PRのCIでは決して実行できないテストレーンがあります:ハードウェアGPUを必要とするピクセルアサーション、実OSでしか信頼できないキーボードショートカットの配送など。テストがそのように分類される経緯は重いテストの判断ルールで扱います。このページは、分類されたの運用をどうするかを扱います。安直な答えは「プッシュ前に開発者のマシンでそのレーンを実行する」 -- ローカルの重いレーン、つまりティアT4です。利便性としてはそれで構いません。しかし唯一のセーフティネットとしては、3つの点で破綻します:

  • バイパス可能である。 ローカルゲートは、誰かが実行するかもしれないし、しないかもしれないスクリプトにすぎません。締切のプレッシャー下、借り物のマシン上、あるいは--no-verifyの陰で、それは静かに実行されなくなります。

  • マシン依存である。 キーボードショートカットのe2eスペックが実WebKit/macOS上でしか信頼できないTauri製テキストエディタアプリを考えてみてください:Linux/WSL2ホストでは同じスイートが何十ものスペックを偽の赤(false-red)にし、実macOSマシンこそがゴールドスタンダードです。ローカルゲートに意味があるかどうかすら、誰のマシンで実行されたかに依存します。

  • 記録(ペーパートレイル)が残らない。 「このレーンが最後に通ったのはいつ、どのハードウェア上で?」に誰も答えられません。ある1つのターミナルのスクロールバックの中にしか存在しなかった緑は、リグレッションゲートではありません。

Note

AIエージェントは「もう一人のコントリビュータ」です。 上の議論はかつてはチームメイトについてのものでしたが、いまではコーディングエージェントにもそのまま当てはまります。サンドボックス内、CI内、あるいはLinuxホスト上で作業するエージェントは、まさにあなたのmacOS専用レーンを実行できないコントリビュータであり、悪意なくローカルゲートを毎回バイパスします。正しい人が正しいマシンで手順を覚えていることを要求するセーフティネットは、ネットではありません。

解決策はローカルレーンを捨てることではなく、それに強制レイヤーの役割を求めるのをやめることです。速度と利便性のためにローカルレーンは残しつつ、スケジュールCI再試験を追加します:重いレーンを、能力のあるハードウェア上でスケジュール実行し、記録を残し、自動でIssueを起票するのです。

2つのコマンド、2つの予算:b4push と exam

スケジュールティアを作る前に、ローカルコマンドを分割します。プッシュ前の利便性パスと、全リグレッションの重い実行は別の仕事であり、別の名前と別の時間予算が必要です:

コマンド内容予算誰が実行するか
b4pushlintゲート、型チェック、影響範囲のユニットテスト、ビルド、CIセーフなスモーク上限付き、5--10分全員、毎プッシュ前、どのマシンでも
exam全リグレッションの重い実行:GPU、WebKit/macOS、長時間フロー上限なしオプトインかつプラットフォームゲート付き:スケジュールCI、または夜間の対応マシン

この分割は特定の失敗モードを防ぎます:1つのコマンドが両方の仕事を徐々に抱え込んでいくことです。プッシュ前パスが予算を超過した瞬間、人も -- エージェントも -- それをスキップし始め、プッシュ前には何も実行されなくなります。examが遅くてよいのは、誰もそれを待って座っていないからこそであり、b4pushが信頼され続けるのは、速いからこそです。

この名前は意図的なものです。すべてのプッシュが試験全体を受けられるふりをする代わりに、プロジェクトが定期的に試験(exam)全体を受け直すのです。

b4push が予算を超えたとき

b4pushが予算を徐々に超えてきたら、この順番でトリムします -- ゲートが「実際に機能している」状態を保つため、ステップごとの計測出力を必須にします:

  1. フルe2e → @smokeサブセット。 クリティカルなジャーニーと、現在のdiffが触れるエリアをカバーするスイートだけを残します。判断基準:フルe2e実行が1〜2分を超えるならスコープを絞る。それ以外は全部スケジュールexamのバックストップに委ねます。

  2. フルユニット実行 → 影響範囲のみ。 turborepo/nxのaffected機能やパッケージフィルタを使い、diffが届くパッケージだけを実行します。すべてのプッシュごとに全パッケージを再実行するブロードなユニットパスが、最もよくある予算肥大化のパターンです。

  3. ドキュメント/サイトのビルド → CI専用。 ローカルのb4pushからdocsビルドを外し、PR CIに任せます。誰もローカルで待たないdocsビルドでも、予算のクリープには着実に貢献します。

各ステップで--reporter=verbose(または同等のオプション)を必須にし、タイミングがログで見えるようにします。トリム後も予算を超えるなら、単純に制限時間を上げてごまかしたくなる衝動に抵抗します。強制された25分ゲートは、スキップされ続ける願望的な10分ゲートよりはるかに優れています -- ただし本当に25分かかるなら、それを正直に名付けて計測します。本当の失敗モードは、READMEの中にしか存在しない予算です。

重コンパイル / ネイティブ(cargo、Rust、…)プロジェクト

上記のカット順序はテストをサブセット化するものです -- コストが実行されるスペックの数に比例することを前提にしています。プッシュ前の支配的なコストがコンパイルであるネイティブプロジェクトでは、その軸が間違っています。V8を組み込んだRustワークスペースは、最初のコールドなcargoビルドに15〜30分かかります;コールドツリー上ではあらゆるコンパイルを伴うステップ(cargo clippycargo test)が予算を吹き飛ばし、サブセット化の軸にできるturborepo/nxの「affected」もcargoには存在しません。予算がスペック数ではなくコンパイル時間で決まるとき、テスト数を絞っても助けになりません。

カット順序のネイティブ版は、代わりにコンパイルの軸に沿ってカットを動かします:

  1. 上限付きの予算はウォームな増分(incremental)ツリーを前提とします。 これは直前のビルドのアーティファクトがディスク上に残っている場合にのみ成り立ちます;コールドツリーでは、どんなテストのサブセット化でも回復できません。

  2. コンパイルを伴うフルスイートはb4pushではなくCIに置きます。 CIが権威あるT1ゲートであり(実行ティアを参照)、ウォームキャッシュ上でフルのcargo clippy / cargo testを実行します。b4pushは最初のコールドコンパイルのコストを払う場所ではありません。

  3. b4pushはコンパイルを伴わない高速チェックだけを実行します -- fmt、format、型チェック、JSテスト -- 加えてウォームツリーのlint(増分ツリーを再利用するcargo clippy。ウォームなら安価で、コールドだと予算を吹き飛ばす張本人です)。

  4. フルのローカルコンパイル/テストはオプトインのenvフラグの後ろにゲートします。 例えばB4PUSH_FULL=1です。フルのローカルパスが欲しいコントリビュータが要求できる一方で、デフォルトは上限付きのままに保たれ、CIが強制レイヤーであり続けます。

Tip

JSのカット順序と同じ原理で、軸だけが違います:あちらはテスト数でカットし、こちらはコンパイルでカットします。デフォルトのb4pushは速く信頼されたまま保たれ、コールドコンパイルのコストは、実際にマージをブロックするゲートであるCIに置かれます。

スケジュールCI再試験ワークフロー

Note

スケジュールのリッチCIはT3 / カットオーバー後の関心事です:プロジェクトのテストスイートが専用のナイトリーランナーを正当化できるほど成熟したカットオーバー時点で構築します。それまでは、ここで説明するローカルexamレーンが暫定手段です。全体の成熟度アークにおける位置づけは実行ティアを参照してください。

完全なスケルトンです。スケジュール実行手動ディスパッチの両方を受け付け、タグ付けされた重いレーン(@gpu@interactive@macos-only -- タグ分類は実行ティアを参照)だけを実行し、失敗時には重複排除されたIssueを起票します:

# .github/workflows/exam.yml
name: exam

on:
  schedule:
    # Nightly at 03:00 UTC -- pick a quiet hour for your timezone
    - cron: "0 3 * * *"
  # On-demand runs for pre-merge escalation (see below)
  workflow_dispatch:

permissions:
  contents: read
  issues: write

jobs:
  exam:
    # GitHub-hosted macOS runners are Apple silicon from macos-14 onward
    runs-on: macos-14
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps webkit

      # Run only the tagged heavy lanes -- everything else already ran on PR CI
      # The json reporter feeds file-exam-issue.sh below; list keeps the live log readable
      - name: Run heavy lanes
        env:
          PLAYWRIGHT_JSON_OUTPUT_NAME: playwright-report/report.json
        run: pnpm test:e2e --grep "@gpu|@interactive|@macos-only" --reporter=list,json

      - name: File or update the failure tracking issue
        if: failure()
        env:
          GH_TOKEN: ${{ github.token }}
        run: bash scripts/file-exam-issue.sh

      - name: Close the tracking issue on green
        if: success()
        env:
          GH_TOKEN: ${{ github.token }}
        run: bash scripts/file-exam-issue.sh --green

ランナーに関する注意

  • GitHubホストのmacOSランナーはAppleシリコンですmacos-14以降):実WebKit、Metalバックエンドのレンダリング。ソフトウェアレンダリングのCIランナー上ではピクセルレベルのスペックが失敗する、canvas/GPUヘビーなWebアプリにとっては、これだけで偽の赤と信頼できる結果の分かれ目になり得ます。

  • サードパーティのホスト型macOSプロバイダも存在します。GitHubホストのプールではスイートに対して遅すぎる、または高すぎる場合の選択肢です。

  • 自前ハードウェアのセルフホストランナーはエスカレーションであって、デフォルトではありません。 エスカレーションする場合:main上でスケジュール実行のみ、パブリックリポジトリでは決してPRトリガーにしない、そしてオフライン検知と組み合わせること -- 例えば、セルフホストジョブがN時間以内に報告してこなかったら警告する、ホスト型ランナー上のコンパニオンジョブ -- これにより、眠っているマシンが「沈黙による緑」ではなく、見える形になります。

Warning

パブリックリポジトリでは、PRがセルフホストランナーをトリガーできるようにしては絶対にいけません。 PRトリガーのセルフホストランナーは、プルリクエストを開いた誰のコードでも、あなたのマシン上で実行してしまいます。セルフホストのexamジョブはmain上のスケジュール実行のみに保ちます。

1つのトラッキングIssue、実行ごとに1つではなく

1週間赤いままのナイトリージョブが、7つのIssueを起票してはいけません。実行ごとの起票はシグナルを重複の山に埋もれさせ、全員にそのラベルを無視することを学習させます。ルールは:ワークフローごとにオープンなトラッキングIssueは1つ -- 失敗が続く間はそこにコメントし、examが緑に戻ったらクローズし、次の失敗には新しいIssueを開かせます。

上のスケルトンのif: failure()ステップは、このスクリプトを呼びます:

#!/usr/bin/env bash
# scripts/file-exam-issue.sh -- one tracking issue per workflow, never one per run
set -euo pipefail

LABEL="exam-failure"
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"

# DRY_RUN=1: echo the gh command instead of running it -- safe to test against a fixture report.json
run_gh() {
  if [ "${DRY_RUN:-}" = "1" ]; then
    echo "+ gh $*"
  else
    gh "$@"
  fi
}

# --green path: close the tracking issue if one is open, then exit.
# Must come BEFORE reading report.json -- green runs produce no failure report.
if [ "${1:-}" = "--green" ]; then
  EXISTING="$(gh issue list --label "$LABEL" --state open \
    --json number --jq '.[0].number // empty')"
  if [ -n "$EXISTING" ]; then
    run_gh issue comment "$EXISTING" --body "Exam green: ${RUN_URL}"
    run_gh issue close "$EXISTING"
  fi
  # No open issue -- nothing to do
  exit 0
fi

# Failure path: collect failing spec names from the reporter's output
# (example: Playwright's JSON reporter written to playwright-report/report.json)
FAILED_SPECS="$(jq -r '.. | objects | select(.ok == false) | .file? // empty' \
  playwright-report/report.json | sort -u)"

BODY="Scheduled exam failed.

Run: ${RUN_URL}

Failing specs:

\`\`\`
${FAILED_SPECS}
\`\`\`"

# Is there already an open tracking issue?
EXISTING="$(gh issue list --label "$LABEL" --state open \
  --json number --jq '.[0].number // empty')"

if [ -n "$EXISTING" ]; then
  # Yes: append this run to it -- do NOT open a duplicate
  run_gh issue comment "$EXISTING" --body "$BODY"
else
  # No: create the single tracking issue with the fixed label
  run_gh issue create \
    --title "exam: scheduled heavy run is failing" \
    --label "$LABEL" \
    --body "$BODY"
fi

各レポートには、修正セッションが必要とする3つの情報が含まれます:固定ラベル(重複排除クエリがIssueを見つけるため)、失敗したスペック名、そして実行URLです。

Note

初回実行前にスクリプトの実行可能ビットをコミットしておきます:git update-index --chmod=+x scripts/file-exam-issue.sh。実際のIssueに触れずにスクリプトをローカルでテストするには、DRY_RUN=1付きで実行します -- run_gh()ラッパーがghコマンドを実行する代わりにエコーするため、フィクスチャ用のreport.jsonに対して両方のパスを検証できます。

レポーターのパースフィクスチャを実際にキャプチャしたレポートに固定する

上記のスクリプトはPlaywrightのJSONレポーター出力をパースしています。このパーサー(またはツールの機械生成出力を消費するスクリプト)のユニットテストを書く場合、テストで使うフィクスチャはツール自体から実際にキャプチャしたアーティファクトでなければなりません -- スキーマの思い込みに合わせて手書きした形ではいけません。

ルール:ツールの機械的な出力をパースするスクリプト -- テストランナーのJSONレポーター、カバレッジJSON、バンドラーのstatsファイル -- のユニットテストを書くときは、ツールの実際の出力を代表的なサブセットにトリムしてフィクスチャとしてコミットします。具体的なレシピ:

  1. 実環境でツールを一度実行する。

  2. そのJSON出力を代表的なサブセットにトリムして __fixtures__/real-report.json として保存する。

  3. そのファイルに対してパーサーのユニットテストを書く。

将来のバージョンでツールがスキーマを変更したとき、テストは声高に失敗します -- 現実と合致しなくなった架空の構造に永遠に同意し続けるのではなく。

この罠にはまっているときの兆候: パーサーのユニットテストは緑なのに、新しいツール実行から得た実際のデータに対してスクリプトが空または無意味な出力を返す。これが、現実ではなく思い込みに合わせたフィクスチャの典型的なシグネチャです。

Warning

合成フィクスチャが安全なのは、あなたがそのコントラクトを所有している場合に限ります。サードパーティツールの出力に対しては、真実を作ることはできません -- キャプチャするしかありません。手書きフィクスチャが適切なのは、テスト対象のスキーマを読者が所有している場合です:たとえば remark/rehypeプラグインのテスト でのmdastツリーファクトリ(mdastコントラクトはあなたが所有)や、レベル3のビルド出力テスト での合成的なインナーバンドルオブジェクト(バンドルの形はあなたが所有)がそれにあたります。PlaywrightやJest、Viteのようなツールの出力スキーマは彼らが変更する権限を持っているため、実際のアーティファクトをキャプチャすることだけが誠実でいられる唯一の方法です。

オンデマンドディスパッチ:マージ前のエスカレーション

スケジュールテストへの定番の反論はフィードバックの遅延です:今朝マージされたリグレッションは明日まで見えない、というものです。workflow_dispatchはこの反論に上限を設けます。スケジュールティアでしかカバーされていないコードに変更が触れるときは、希望的観測でマージしないこと -- そのブランチに対してexamをディスパッチし、判定を待ちます:

# The change touches code covered only by scheduled-tier tests?
# Run the exam on the branch BEFORE merging -- do not wait for tonight's cron.
gh workflow run exam.yml --ref my-feature-branch

# Follow the run to its verdict
gh run watch "$(gh run list --workflow=exam.yml --limit 1 \
  --json databaseId --jq '.[0].databaseId')"

これにより、スケジュールティアの最大の弱点が上限付きのコストに変わります:デフォルトのフィードバックループは夜間で、本当に待てない変更には手動の脱出ハッチが用意されている、というわけです。

夜間試験:プロジェクトスコープのエージェントスキル

スケジュールCIジョブは意図的に薄く作ります:実行し、報告し、起票するだけ。同じアイデアのよりリッチなバージョンは、テストが最も信頼できる場所 -- ゴールドスタンダードのマシンそのもの -- の上で、夜間に、素のCIにはできない部分をエージェントが担う形で実行します。

これをプロジェクトスコープのエージェントスキルとして定義します:リポジトリのエージェント設定にチェックインされたスラッシュコマンド形式のエントリポイントです。手順がバージョン管理され、レビュー可能で、毎晩同一になります。就寝前に手動で起動します:

# Before sleep, on the gold-standard machine
/exam          # run heavy lanes, triage failures, file deduped issues
/exam --fix    # ...and additionally pick up to 3 issues and fix them in-session

スキルのパイプライン:

  1. プリフライト -- ツリーがクリーンで、ブランチがmainで、リモートに対して最新でない限り、開始を拒否する

  2. スリープ防止ラッパー -- スイートの途中でマシンが眠らないようcaffeinate -iの下で実行する

  3. プラットフォームゲート付きの重いレーンを実行 -- CIのexamと同じタグを実行する

  4. エージェントによるトリアージ -- 失敗をクラスタリングし、既知の環境ノイズシグネチャを本物のリグレッションから分離する

  5. 失敗クラスタごとに重複排除されたIssue -- クラスタごとに1つ。スペックごとでも実行ごとでもなく

  6. --fixモード -- 起票されたIssueから最大N件を拾い、そのセッション内で修正し、朝のレビューに備える

  7. 朝のサマリー -- 1つのメッセージ:何が実行され、何が失敗し、何がノイズで、何が起票され、何が修正されたか

最初の2ステップは素のシェルです:

# Preflight -- refuse to run on a dirty or stale tree
git status --porcelain | grep -q . && { echo "dirty tree"; exit 1; }
[ "$(git branch --show-current)" = "main" ] || { echo "not on main"; exit 1; }
git pull --ff-only

# Keep the machine awake for the whole run (macOS)
caffeinate -i pnpm test:e2e --grep "@gpu|@interactive|@macos-only"

トリアージのステップこそが、これがcronスクリプトではなくエージェントスキルである理由です。ゴールドスタンダードのマシン上では赤はおそらく本物ですが、長く生きている重いスイートには既知のノイズシグネチャが蓄積していきます:初回実行のフォントキャッシュ警告、コールドブート直後のタイミング依存のファーストフレームなど。エージェントは失敗をクラスタリングし、プロジェクトのエージェント指示に記録されたノイズシグネチャと照合し、残ったものだけをIssueにします。この判断 -- 「この3つの赤は1つのリグレッション、4つ目の赤は火曜日からある既知のノイズ」 -- こそ、素のCIジョブには決してできないことです。

Note

それでもスケジュールCIジョブは残します。 夜間試験は、人間が実行を覚えていることと、マシンが起きたままでいることに依存します -- まさにローカル専用レーンを失格にしたのと同じ失敗モードです。ペアであることに意味があります:夜間試験はトリアージと修正を備えたリッチなレーン、スケジュールCI再試験は誰も覚えていなかった夜にも必ず実行される薄いバックストップです。

実装時のスコープを絞った重いテスト実行

cronも夜間試験も事後的なものです:変更が取り込まれてから何時間も後にリグレッションを捕まえます。重いレーンのテストでしかカバーされていないコードに触れる変更を実装しているときは、今夜を待たないこと -- 関連する重いスペックだけを、変更にスコープを絞って、対応できるホスト上で今すぐ実行します:

# The change touched the shortcut engine -- run just its heavy specs,
# on the capable host, before declaring the work done
pnpm test:e2e --grep "@interactive" e2e/shortcuts-*.spec.ts

難しいのはスペックの実行ではなく、変更がどのスペックに関係するかを知ることです。それには変更→スペックのマッピングが必要で、2つの規約で機械的に保てます:

  • Issue番号付きのスペックファイル名 -- e2e/issue-123-shortcut-paste.spec.tsのような名前は、スペックをその動機となった変更に紐付け、grepで見つけられるようにします

  • プロジェクトのエージェント指示内のモジュール→スペック表 -- エージェント(または人)が、変更がどの重いスペックに関係するかを引けるようにします:

<!-- In the project's agent instructions: change-to-spec mapping -->

| When a change touches... | Run these heavy specs first            |
| ------------------------ | -------------------------------------- |
| src/shortcuts/**         | e2e/shortcuts-*.spec.ts (@interactive) |
| src/render/gpu/**        | e2e/render-*.spec.ts (@gpu)            |
| src/export/video/**      | e2e/export-video.spec.ts (@gpu)        |

Tip

スコープを絞った実行は、口伝ではなくエージェント指示内の明文化された要件にします:「重いレーンのテストでしかカバーされていないコードに変更が触れた場合、作業完了を宣言する前に、マッピングされたスペックを対応ホスト上で実行する(またはそのブランチでexamワークフローをディスパッチする)」。

レイヤー化された全体像

サーフェス実行内容タイミング失敗時
b4push(ローカル)高速な上限付きパス毎プッシュ前プッシュ前に修正
PR CICIセーフなゲートすべてのPRマージブロック
スケジュールexam(CI)macOSランナー上のタグ付き重いレーン夜間cron + 手動ディスパッチ重複排除されたトラッキングIssue
夜間試験(ローカルスキル)重いレーン + エージェントトリアージ就寝前に手動クラスタごとのIssue、任意で--fix
スコープ付き重い実行(ローカル)変更に関連するスペックのみ実装中完了宣言の前に修正

どの単一のサーフェスもセーフティネットではなく、レイヤリングこそがネットです。そもそもどのテストが重いレーンに属するべきかは重いテストの判断ルールが決め、全体で使われるティアの語彙(T0--T4)は実行ティアで定義されています。

Revision History

Takeshi Takatsudo作成: 2026-06-12T21:41:36+09:00更新: 2026-06-17T02:14:42+09:00