Rustスイートをcargoからnextestへ移行する
このガイドのリトライ予算・テストごとのタイムアウト・flakyテレメトリのポリシーが、プレーンな cargo test では実装不可能である理由と、Rustスイートをcargo-nextestへ移行するための機械的なチェックリスト。
なぜnextestがRustのflakeパイプラインのゲートなのか
重いテストの判断ルールと実行ティアは、このガイドのすべてのCIスイートに同じ3つの義務を課します:1〜2回のリトライ予算、トリアージのシグナルとして記録されるpass-on-retry、そして1つのテストがハングしてもジョブ全体をハングさせない、テストごとのタイムアウトです。Rustプロジェクトでは、これらはプレーンな cargo test の上では単に扱いづらいだけではなく — 実装不可能です。cargo test にはリトライの仕組みも、テストごとのタイムアウトも、安定した機械可読な出力もありません。cargo-nextest はその3つすべてを提供します。だからこそRustのflakeパイプラインは、組み込みのハーネスではなくnextestを通して動くのです。
| Policy requirement (this guide) | plain cargo test | cargo nextest |
|---|---|---|
| CI retry budget 1–2 | none | retries = 2 |
| Pass-on-retry recorded as triage signal | invisible | reported as FLAKY, in JUnit |
| Per-test timeout (no hung CI jobs) | none (job-level only) | slow-timeout + terminate-after |
| Machine-readable output for issue filing | none stable | JUnit XML |
リトライ予算は retries に、pass-on-retryのトリアージシグナルはnextestのFLAKYレポートとJUnit出力に、テストごとのタイムアウトは slow-timeout + terminate-after に、issue起票のための機械可読なフィードはJUnit XMLに対応します。このガイドの各ポリシー行には、直接対応するnextestの仕組みがあり、cargo test には等価物がありません。
設定のスケッチ
ポリシーは2つのファイルが担います。nextestのプロファイルがリトライ予算・テストごとのタイムアウト・JUnitテレメトリをエンコードし、さらにプレーンな cargo test がスイートに無償で与えていた直列化を再確立します:
# .config/nextest.toml
[profile.ci]
retries = 2 # the CI retry budget (1–2)
slow-timeout = { period = "60s", terminate-after = 4 } # per-test timeout
[profile.ci.junit]
path = "junit.xml" # flake telemetry / deduped issue filing
# Preserve the serialization the suite silently relied on under cargo test:
[test-groups]
heavy-serial = { max-threads = 1 }
[[profile.default.overrides]]
# every integration-test binary that boots a dev server / binds ports / embeds V8
filter = 'binary(dev_serve_e2e) | binary(dev_build_static_parity) | binary(dev_serve_injected_routes_e2e)'
test-group = 'heavy-serial'CIステップは cargo test を cargo nextest run に置き換えます — ただしnextestはdoctestを実行しません。そのため、doctestのステップは明示的に残す必要があります:
# CI
- uses: taiki-e/install-action@nextest # cargo nextest is not part of the default toolchain — install it first
- run: cargo nextest run --workspace --profile ci
- run: cargo test --doc --workspace # nextest does not run doctests — drop this step and doctest coverage silently disappearsWarning
cargo test --doc のステップを削ることは、静かなカバレッジの喪失です。 nextestはdoctestを一切実行しません。移行が cargo test を cargo nextest run に置き換えるだけで他に何もしなければ、すべてのdoctestが実行されなくなり、それでもスイートはグリーンになります。doctestの実行は独立したステップとして残してください。
移行チェックリスト(機械的)
まずdoctestのサーフェスを計測する — フェンスを分類する(実行可能な
rustと、rustdocが決して実行しないtext/ts/html/json)。実際の数を移行PRに記録する。cargo test --doc --workspaceは独立したステップとして残す。(言い伝えの数は、たいてい計測した数よりはるかに多いものです。)暗黙の直列化を列挙する —
cargo testはテストバイナリを順番に実行しますが、nextestはバイナリをまたいで並列化します。ポートにバインドする/サーバを起動する/重量級ランタイムを埋め込む、すべての統合テストバイナリをリストアップし、最初のflake stormの後ではなく、最初の並列実行の前にmax-threads = 1のテストグループに入れる。防御を厚くするため、共有のtest-utilsクレートにアドバイザリなflockを追加してもよい。コメント中の並行性の主張を監査する — 主張されている仕組み(
flock、ロックファイル、mutex)をgrepし、信頼する前にそれが実際に存在することを確認する。インベントリを突き合わせる — バイナリごとに
cargo test -- --listとcargo nextest listを差分し、すべての差分をPRで説明する。静かな欠落(nextestが異なる基準でフィルタするテスト)が主要な失敗モードです — より少ないテストを実行するグリーンな移行PRは、移行しないより悪いのです。隔離の相互運用 — 既存の
#[ignore]隔離ノートはきれいに対応します:T3の失敗許容ジョブはcargo nextest run --run-ignored ignored-only(任意で名前フィルタ、現在のノートと同じ注意点)になります。
Note
ステップ5は、このレシピを重いテストの判断ルールの隔離パイプラインへ結び付けます:そこで説明されている #[ignore] ベースの隔離は、nextestの下では cargo nextest run --run-ignored ignored-only として実行され、同じ名前フィルタの注意点が当てはまります。
タグ分類の実行
重いテストの判断ルール § 同じルール、Rust構文の理由文字列タグ分類は、あるテストがなぜ #[ignore] を持つのかを分類します。このセクションは、nextestがその分類を実際の実行レーンへどう変換するかを扱います。
--run-ignored:隔離/エグザムレーン
cargo nextest run --workspace --profile ci # T1: the PR gate -- never runs an #[ignore]'d test, whatever its reason
cargo nextest run --workspace --profile exam --run-ignored ignored-only # T3: the quarantine/exam lane -- runs ONLY the ignored set
cargo nextest run --workspace --profile exam --run-ignored all # full-inventory sanity run -- everything, ignored and not; not a gate--run-ignored ignored-only は、定期再試験にあるPlaywrightの --grep "@flaky" 隔離レーンのnextest版です — ただし、こちらは理由文字列の中身ではなく #[ignore] の有無で選択します。nextestは引用符の中身を一切パースしません。あの文字列は人間とgrepのためのものであり、nextest自身のフィルタリングのためのものではないのです。heavy: と flaky: の理由が混在するignore済みセットを持つワークスペースは、両方を同じ ignored-only の実行にまとめて走らせてしまい、Playwright側が独立した --grep 式で保っていた区別を消してしまいます。両者を別々に判断する必要があるときは、名前フィルタで絞り込みます:
# separate "expected to always pass, just slow" from "expected to sometimes fail"
cargo nextest run --profile exam --run-ignored ignored-only -E 'test(/^heavy_/)'
cargo nextest run --profile exam --run-ignored ignored-only -E 'test(/^flaky_/)'これは、上の隔離のNoteがすでに flaky: 単体について挙げている注意点を一般化したものです:#[ignore] は @flaky に対してだけでなく、分類全体にわたって、どの単一の理由よりも広いマーカーなのです。
test-groups:エグザムレーン内での予算・直列化コントロール
上のConfig Sketchは、ポートにバインドする統合バイナリを再直列化するために test-groups エントリ(heavy-serial)を使っています。エグザムレーンにも同じ仕組みが別の理由で必要です:エグザムレーンは遅くてもよいのですが、無制限ではありません — heavy: でignoreされたセットがランナーのスレッドを丸ごと飽和させてしまうと、「少数の遅いテスト」がエグザムレーン自身の重量級スイート問題に変わってしまいます。
新しいグループは、上のConfig Sketchが開いた既存の [test-groups] テーブルに追加します — 同じファイルに2つ目の [test-groups] ヘッダーを置くのは無効なTOMLです。テーブルを2回宣言することはできません:
# .config/nextest.toml -- inside the [test-groups] table already opened by the Config Sketch
heavy-serial = { max-threads = 1 } # existing, from the Config Sketch above
exam-heavy = { max-threads = 2 } # new: caps concurrency for `heavy:`-ignored tests specificallyexam プロファイル自体は新規なので、独自のテーブルを持ちます:
# .config/nextest.toml (new tables, appended after the Config Sketch above)
[profile.exam]
retries = 0 # exam is diagnostic, not gated -- no retry budget to hide a real fail
[profile.exam.junit]
path = "junit.xml" # same machine-readable feed the ci profile uses, for the filing script
[[profile.exam.overrides]]
filter = 'test(/^heavy_/)' # name-convention filter narrowing to the `heavy:` reason
test-group = 'exam-heavy'専用の [profile.exam] が必要なのは — ci プロファイルに --run-ignored を後付けするのではなく — エグザムレーンのリトライ・スレッド化のデフォルトがPRゲートのそれとは正当に異なるからです:T1は本物のリトライ予算を求めます(リトライ予算を参照)。エグザムレーンが求めるのはゼロです。そうすることでパスは曖昧さがなく、失敗は本物になります。
失敗許容のスケジュール実行エグザムジョブ
このスケジュールジョブは、定期再試験 § flakyテレメトリのメカニズムにあるPlaywright隔離レーンの終了コードキャプチャパターンをそのまま踏襲します:continue-on-error: true と if: failure() の組み合わせがあちらでフィリングステップを死んだコードにしてしまうのと同じ理由で、こちらでも同じことが起こります — ステップレベルの continue-on-error はそのステップの conclusion をsuccessに書き換えてしまうため、下流の if: failure() は決して発火しません。
# .github/workflows/exam.yml
name: exam
on:
schedule:
- cron: "51 3 * * *" # off-minute on purpose -- see Scheduled Re-exam
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
exam:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@nextest
- name: Run the quarantine/exam lane (allowed-to-fail BY DESIGN)
id: exam
run: |
set +e
cargo nextest run --workspace --profile exam --run-ignored ignored-only
rc=$?
set -e
echo "rc=$rc" >> "$GITHUB_OUTPUT"
# Deliberate: this lane is green-by-design. The captured rc -- not the
# job status -- drives the filing step below.
exit 0
- name: File or update the tracking issue
if: steps.exam.outputs.rc != '0'
env:
GH_TOKEN: ${{ github.token }}
run: bash scripts/file-exam-issue.shfile-exam-issue.sh は、定期再試験にある1ワークフロー1トラッキングissueパターンと同じものです — ラベルとタイトルによる重複排除、コメントか新規作成かの判定、グリーン時のクローズというロジックはそのまま移植できます。異なるのは失敗抽出のステップだけです:Playwrightの JSON レポーターの代わりに、上の [profile.exam.junit] が生成する junit.xml をパースします。
次に読むページ
重いテストの判断ルール — このnextest設定が実行する
#[ignore]理由文字列タグ分類の全体(env-gate:、heavy:、flaky:、verification:、pending-feature:)と、Rustのflakeがどう隔離されるか実行ティア — この移行がRustスイート上で実装可能にするために存在する、リトライ予算
フレイク根本原因カタログ & デフレイキングレシピ — nextestがバイナリをまたいで並列化した瞬間に表面化する、ネイティブスイートのflake原因(ポートレース、暗黙の直列化、共有状態)