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: PR gate -- no quarantined tests; env-gated ignored tests use explicit lanes
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 gatePRゲートでは隔離されたテストを実行対象から外しますが、--profile ci 自体が #[ignore] の理由を判別するわけではありません。実行環境の要件だけを理由に #[ignore] が付いたテストは、後述するYAMLのenv-gateレーンのように、名前フィルタで対象を絞った明示的な --run-ignored ignored-only レーンを設け、--profile ci で実行します。
--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 をパースします。
移行後:証拠を失わずに最適化する
ここまでの内容は、スイートを移行して正しくするところまでです。ここから先は、そのスイートがゲートのクリティカルパスでもあるときに必要になることです。グリーンな実行が決して報告しない2つのことが、ここで壊れます:単一の max-threads = 1 グループが、実際には競合していなかったクラスまで静かに直列化しすぎること。そして、その実行にどれだけコストがかかったかを証明するはずのJUnitレポートが、同じジョブ内の次のコマンドに上書きされてしまうことです。
このセクションの実測値は、あるRustゲートから取ったもので、レーンと時計の両方でラベルを付けてあります:3レーンにまたがる 63 件の #[ignore] 付きenv-gateテスト、5,807 件のワークスペーステスト、457 件のrequired-featuresテスト。監査した 25 個のJUnitファイルには 50,301 件の <testcase> レコードが含まれ、そのうちflaky・failure・errorのレコードはゼロ。必須(required)の health ジョブが 18m30s、ゲート全体のウォールクロックが 19m42s です。
実証済みの競合クラスに沿って直列化を分割する
Config Sketchの heavy-serial が1つのグループなのは、移行時に答えるべき問いが1つだったからです:どのバイナリがこれまで1つずつ実行されていたのか? スイートがグリーンになった後、そのグループはたいてい2つ以上の異なる競合クラスを一緒に抱え込んでおり、グループ全体がその和集合の速度で走ることになります。
実例のゲートでは、V8/esbuildを積んだ41個の統合テストバイナリが1つの max-threads = 1 グループを共有していました。そのうち24個は、上の移行チェックリストのステップ2にあるプロセスレベルのアドバイザリ flock をすでに採用しており、残りの17個はflockを一切取らないビルド専用のバイナリでした。この2つのクラスは互いには競合せず、それぞれのクラス内でしか競合しません。つまり、独立した2つの max-threads = 1 グループなら、flock採用バイナリとビルド専用バイナリを同時に走らせながら、各クラス内の直列化は保てるのです:
# .config/nextest.toml -- REPLACES the Config Sketch's `heavy-serial` group and
# its single override. Paste this fragment in their place rather than beside
# them: a second [test-groups] header in the same file is invalid TOML, and a
# binary claimed by two overrides is a configuration error, not a wider group.
[test-groups]
heavy-serial-flock = { max-threads = 1 }
heavy-serial-build = { max-threads = 1 }
exam-heavy = { max-threads = 2 } # unchanged, from the exam lane above: its override still names this group
[[profile.default.overrides]]
# Class A: binaries that take the process-level advisory flock (checklist step 2).
filter = 'binary(dev_serve_e2e) | binary(dev_serve_injected_routes_e2e)'
test-group = 'heavy-serial-flock'
[[profile.default.overrides]]
# Class B: build-only binaries that never take the flock. Disjoint from Class A
# by construction -- no binary() name appears in both filters.
filter = 'binary(dev_build_static_parity) | binary(dev_build_asset_graph)'
test-group = 'heavy-serial-build'同一SHAでの3回のホステッド実行により、この分割は 必須(required)の health ジョブ 時間の中央値を22m22sから18m54sへ短縮しました — ここでの時計は、ゲート全体のウォールクロックではなく、その1つのジョブ自身の所要時間です — 5,807件中5,807件のテストを実行し、リトライもゼロ、失敗もゼロでした。この区間はこの1つの波自身の始点と終点です。このセクション冒頭の18m30sは、その後のすべての波を経たあとの同じジョブ時計なので、2つの数字は異なる端点であって食い違いではありません。
判断ルールは意図的に狭くしてあります:2つのクラスが互いに競合しないことを独立に実証できたときだけ分割する。 実証とは、各クラスの隔離メカニズムを特定し、それが実在することを確認済み(移行チェックリストのステップ3)だという意味であって、2つのグループがたまたま一度グリーンになった事実からの推測ではありません。当てずっぽうの分割は、max-threads = 1 グループが抑え込むために作られたポートレースや共有状態のレースを、そのまま呼び戻します。しかも設定エラーとしてではなく、断続的な失敗として呼び戻すのです。
この実例で計測・確認したのは、同一SHAでのホステッド実行における必須の health ジョブの所要時間の中央値、リトライ、テストアイデンティティです。ピークメモリ使用量は計測していないため、両クラスを同時に実行してもメモリが不足しないことまで、この結果から保証することはできません。
分割は証拠に基づいて受け入れ、その証拠を明記します:同一SHAでのホステッド実行を3回以上取った中央値、テストアイデンティティが変わっていないこと(比較のキーはテストタイトルではなくテストアイデンティティにします)、新規のリトライがゼロであること、そしてすべてのアーティファクトが保持されていることです。中央値は、ジョブの典型的な所要時間についての主張には正しい統計量です。テールについての主張は別の主張であり、別の統計量を必要とします — fullyParallel のフリップがまさにその理由で 5回以上の試行の最大値 を比較しており、このセクションはそれを置き換えるものではありません。所要時間はジョブレベルのタイムスタンプから読み、リトライ予算はすべてのサンプルで固定します — リトライ予算を使ってウォールクロックを買った分割は、何も買っていません。
重量級グループのメンバーシップを機械的に強制する
上の2つの binary(...) フィルタはどちらも手書きのリストであり、手書きのリストはドリフトします。しかもその失敗は非対称です:削除済みのバイナリを指す古いエントリは声高ですが、新しく追加された、誰も分類していない統合テストは静かです — それはデフォルトグループに落ち、グループが直列化するために存在するまさにそのバイナリたちに対して完全並列で走り、実際に壊れるまでスイートはグリーンのままです。
ガードは、同じゲートの中で走るチェックで、5つのステップからなります:
候補集合をすべての統合テストのソースファイルから導出する — ワークスペース全体の
tests/*.rsターゲットからであって、#[ignore]の集合からでも、2つ目の手書きリストからでもありません。守るべき設定そのものを入力にしたガードは、何も証明しません。明示的な除外リストを差し引く。 意図的に未分類にしたバイナリごとに1エントリ、それぞれ直前の行にレビュー済みの理由を書きます。実例のゲートには レビュー済みの除外が5件 あります。成果物は理由のほうであって、件数ではありません。
設定された2つの
binary(...)フィルタの和集合と完全一致することを要求する。 導出した集合から除外を引いたものが、その和集合と双方向に等しくなければなりません。2つのグループが互いに素であることを要求する。 積集合は空でなければなりません。そうでなければ、nextestが先に評価したほうのoverrideが、どのバイナリを黙って取り込んでもおかしくありません。
共有ターゲットのテスト述語クラスを固定する — バイナリをまたいで選択する
test(...)述語のことです — 新しい述語が、このチェックを落とさずにバイナリをグループの外へ移してしまえないようにします。
要となるのはステップ3と4の組み合わせです。「設定されたすべての binary(...) 名が実在のバイナリに解決される」という片側だけのアサーションは、等式の反対側に未分類のソースが積み上がっていく間、永遠にパスし続けます。これは無視テストマニフェストのパリティ(古いマニフェスト行と、行き場を失ったテストが同じように落ちなければならない)と同じ双方向インベントリの形であり、カウンターウェイト3:マニフェストパリティ(チャンク単位のアサーションは、そのチャンクごと実行されなくなったことに気づけない)とも同じ形です。
次の同一プロファイル実行の前に、すべてのJUnitレポートを退避する
[profile.<name>.junit] path は target/nextest/<profile>/ の下に解決されるため、あるプロファイルの すべての 実行が同じファイルに書き込みます。したがって、1つのジョブ内で cargo nextest run --profile ci ... を2回続けて実行すると、残るレポートはちょうど1つ、最後の生成者のものだけです。実例のゲートは同一プロファイルのコマンドを5本走らせます — 3本の --run-ignored ignored-only env-gateレーン、--workspace の実行、そしてrequired-featuresの実行です — つまり、各実行の間に退避を挟まなければ、5つのうち4つのレポートが消えることになります:
# .github/workflows/health.yml (excerpt) -- five same-profile nextest runs in one
# job. Each report is moved out of target/nextest/ci/ BEFORE the next run
# overwrites it, because the profile writes the same path every time.
- run: cargo nextest run --profile ci --run-ignored ignored-only -E 'test(/^env_gate_/)'
env:
ENV_GATE: docker
- if: always() # keep the report from a FAILED run too -- that is the one worth reading
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/env-gate-docker.xml
- run: cargo nextest run --profile ci --run-ignored ignored-only -E 'test(/^env_gate_/)'
env:
ENV_GATE: network
- if: always()
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/env-gate-network.xml
- run: cargo nextest run --profile ci --run-ignored ignored-only -E 'test(/^env_gate_/)'
env:
ENV_GATE: credentials
- if: always()
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/env-gate-credentials.xml
- run: cargo nextest run --workspace --profile ci
- if: always()
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/workspace.xml
- run: cargo nextest run --workspace --profile ci --all-features # builds the targets gated behind [[test]] required-features
- if: always()
run: mkdir -p reports && mv target/nextest/ci/junit.xml reports/required-features.xml
- if: always()
uses: actions/upload-artifact@v4
with:
name: junit-reports
path: reports/
if-no-files-found: error # an empty upload is the silent failure this block exists to prevent各退避ステップの if: always() は飾りではなく、このパターンの要点そのものです:失敗したレーンこそ、そのレポートを最も読みたいレーンだからです。if: always() がなければmoveはスキップされ、証拠はステップと一緒に死にます。退避は必ずその生成者とペアにし、最後にまとめて5つ集めようとしないでください — 失敗のあとは後続のレーンが走らないため、末尾に置いた1つの集約ステップは、たまたまディスク上に最後に残ったレポートしか見られません。
ここでアップロードを無条件にしていることは、明記しておく価値があります。このサイトで最も近いルールは逆を指しているからです。Playwrightのトレースはfailure() || cancelled() でゲートすることになっています。グリーンな実行すべてからトレースディレクトリ一式を上げると、診断上の利得なしにストレージ上限を食い潰すからです。一方JUnitレポートはキロバイト単位で、しかもグリーンな実行にとってもリトライの証拠です — そうでなければ次のセクションの計算式には読むものがありません — なので毎回アップロードします。共通するルールは、アップロードの条件はアーティファクトのクラスごとに下す判断であって、他のページから受け継ぐデフォルトではない、ということです。そして if-no-files-found: error は、「moveが全部静かに空振りした」状態を、空のアーティファクトではなく赤いジョブに変えてくれます。
上書きそのものは、道具を替えただけの同じ罠です:2回目のPlaywright実行が、アップロードの前に outputDir を空にする。形も同じ、対処も同じです — ツールが再び走る前に、アーティファクトをそのツール自身の出力ディレクトリの外へ移すこと。
1つだけ、レポートの話ではなく上のブロック自体に付けておくべき但し書きがあります:あの中の --all-features の実行が組み立てるのは、ワークスペースのフィーチャの最大の和集合であり、これは見た目より狭い主張です — 和集合のビルドが個々のクレートのマニフェストについて何を語れないのかは、クレートの分離とフィーチャの所有権を参照してください。
実際のnextest JUnitの形からリトライに値札を付ける
パスに見える <testcase> でも、3回の試行を費やしている可能性があります。そしてそのコストを運ぶ要素は <flakyFailure> だけではなく、全部で4種類あります。<flakyFailure> だけを探すパーサーは、リトライ付きの素の失敗をゼロ円で見積もってしまいます。
<!-- Elided for shape: the <testsuites>/<testsuite> wrappers, the timestamp
attributes, the <system-out>/<system-err> children, and the full captured
panic text that each element body actually carries. -->
<!-- flaky PASS. The testcase is a success and its time is the PASSING attempt.
Every earlier attempt nests as flakyFailure -- flakyError for an execution
failure or a leak -- each with its own time. -->
<testcase name="test_flaky_mod_4" classname="fixture-project::basic" time="0.004">
<flakyFailure time="0.004" type="test failure">attempt 1 panicked</flakyFailure>
<flakyFailure time="0.005" type="test failure">attempt 2 panicked</flakyFailure>
</testcase>
<!-- flaky FAIL under the default flaky-fail-status = "failure": a failure element
whose type is the literal "flaky failure", PLUS the same nested
flakyFailure/flakyError set. testcase@time is the PASSING attempt here too.
Under flaky-fail-status = "success" this testcase is indistinguishable from
the flaky PASS above while the run itself still fails -- pin the knob or the
report cannot be read at all. -->
<testcase name="test_known_flaky" classname="fixture-project::basic" time="0.006">
<failure type="flaky failure">flaky: passed only on a retry</failure>
<flakyFailure time="0.004" type="test failure">attempt 1 panicked</flakyFailure>
<flakyFailure time="0.005" type="test failure">attempt 2 panicked</flakyFailure>
</testcase>
<!-- plain FAILURE with retries: failure/error plus one rerunFailure or rerunError
per retry. Here testcase@time is the FIRST attempt, not the last -- the
opposite end of the sequence from the two flaky cases above. -->
<testcase name="test_failure_assert" classname="fixture-project::basic" time="0.004">
<failure type="test failure">assertion failed: left == right</failure>
<rerunFailure time="0.004" type="test failure">assertion failed: left == right</rerunFailure>
<rerunFailure time="0.004" type="test failure">assertion failed: left == right</rerunFailure>
</testcase>計算式は2つ、入力は1つです。任意の <testcase> について:
リトライ込みの時間 =
testcase@timeに、その配下にネストしたflakyFailure・flakyError・rerunFailure・rerunErrorの@timeの総和を足したもの。リトライ税 = そのネスト分の総和そのもの。
testcase@time は上の3つの形すべてにおいて、常にちょうど1回の試行であり、決して合計ではありません。これをテストの総実行時間として読むダッシュボードは、リトライされたすべてのテストを、リトライ税まるごとの分だけ過小評価します。
cargo-nextest 0.9.138 から取得 — ピン留めしているバージョンに対して要素の集合を再確認してください。flaky-fail-status(0.9.131以降)と report-skipped(0.9.143以降)は形を変えます。
これらをパースするものには実際にキャプチャしたレポートをフィクスチャにするルールが効きますが、ここではそのルールに鋭い角があります。実例のゲートで監査した25個のレポートは50,301件の <testcase> レコードを持ちながら、flaky・failure・errorのレコードは ゼロ でした — 健全なスイートはネストしたリトライ要素をまったく生まないため、通常のアーティファクト回収では、どちらの計算式も一度も動かされません。フィクスチャは意図的にキャプチャしてください:retries >= 1 の下で1回目の試行が必ず失敗するテストを仕込み、その実行の junit.xml を残します。それができない場合は、そのサンプルを自分のスイートからキャプチャしたものではなく、nexte.stのドキュメントから再現したものだと明記してください — 思い込みで組み立てた合成的な形こそ、フィクスチャのルールがテストから締め出そうとしているものなのです。
クレートの分離とフィーチャの所有権
cargo nextest run --workspace --profile ci --all-features は、ゲートが走らせる中で最も幅の広いビルドです。そしてその幅こそが、この実行を過大に読み取らせる原因です。Cargoは依存クレートのフィーチャ集合を一度だけ解決し、そのビルドのために選択されたすべてのワークスペースメンバーにわたって統合します。--all-features は、選択された各メンバーが宣言しているフィーチャをすべて有効にします。つまりこの実行が証明するのは、その結果できあがった和集合がコンパイルでき、テストに通るということです。個々のクレートの Cargo.toml が、そのクレート自身のコードに必要なフィーチャを宣言できているかどうかは、まったく証明していません。あるクレートは、自分では一度も要求していないフィーチャを使いながら、何年もグリーンであり続けられます。兄弟クレートが要求してくれているからです。
resolver = "2" を指定してもこの穴は塞がりません。v2リゾルバがやめるのは、依存の種類(通常・build・dev)をまたぐ統合と、ターゲット固有のエッジをまたぐ統合です。選択されたワークスペースメンバー同士の統合は依然として行われ、この失敗が乗っているのはまさにその統合です。
あなたのフィーチャを所有している兄弟クレート
あるクレートが reqwest::blocking::Response::json() を呼び出しているのに、自身のマニフェストでは blocking と rustls-tls しか宣言していませんでした。そして兄弟クレート — そのクレートに依存しておらず、そのクレートから依存もされておらず、共有しているのはワークスペースだけ — が reqwest/json を宣言していました。兄弟がそのフィーチャを持ち続けている限り、--workspace のビルドも --all-features の実行もすべてグリーンでした。破綻は無関係なクリーンアップとしてやってきます:兄弟がJSONを必要としなくなり、json が兄弟のフィーチャ一覧から消え、実際に json() を呼んでいたクレートがコンパイルできなくなったのです。そのクレートは何ヶ月も変更されていませんでした。
2つのマニフェストで再現できます:
# consumer/Cargo.toml -- calls Response::json(), and never declares `json`
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }# sibling/Cargo.toml -- unrelated to consumer, and the only crate that owns `json`
[dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json"] }cargo check --workspace # green: one reqwest, resolved with blocking + rustls-tls + json
cargo check -p consumer # RED: `Response::json` is behind a feature consumer never declared2行目がこの話のすべてです。2つのコマンドの間で consumer については何一つ違いません — 違うのはどのパッケージが選択されたか、したがって誰のフィーチャ宣言がまとめ上げられたか、それだけです。
検知するのは、隔離された cargo check -p レーン
潜在的な欠陥をコンパイルエラーに変えるのはパッケージ選択の絞り込みです。したがってゲートには、対象のクレートをワークスペースの和集合の一員としてではなく、選択されたパッケージとしてコンパイルするレーンが必要です:
# .github/workflows/health.yml (excerpt) -- isolated manifest lanes. They run IN
# ADDITION to the --workspace run above, never instead of it: that run proves the
# union builds, these prove each manifest stands up on its own.
- run: cargo check -p consumer --all-targets
- run: cargo check -p sibling --all-targets--all-targets は要となるフラグです。素の cargo check -p がコンパイルするのはlibとbinだけです。tests/ や benches/ からしか使われていないフィーチャも、src/ から使われているフィーチャとまったく同じように所有されていない可能性があり、このフラグがなければそれらのターゲットはそもそもコンパイルされません。
対象のクレートは、件数ではなく契約で選びます。マニフェストが隔離レーンに値するのは、ワークスペースの外にいる誰かがそれを信じるときです:cargo publish するもの、あなたの Cargo.lock なしに下流の利用者がビルドするもの、そしてランタイムのアーティファクトに同梱されるもの。40クレートのワークスペースで内部ヘルパークレート1つ1つに隔離レーンを置けば、ずっと小さな保証のために、ゲートの時計のずっと大きな割合を買うことになります — これはスイート全体の予算の議論を、テストレーンではなくcheckレーンに当てはめたものです。
隔離した選択が別のフィーチャ集合に解決される場合 — まさにこのレーンが捕まえようとしているのがその場合です — ワークスペースビルドの成果物は再利用できませんが、それは直すべきキャッシュの不具合ではありません:フィーチャ集合が違えばフィンガープリントも違うので、その選択のために reqwest はコンパイルし直されます。その再コンパイルこそがこのチェックの代金であり、レーンの一覧がワークスペースの全メンバーではなく、レビュー済みの短い集合であるべき理由です。
これらは --workspace --all-features の実行を置き換えるものではありません。[[test]] required-features の背後にあるターゲットをビルドする唯一のレーンは依然としてあちらですし、すべてが有効なときにだけコンパイルが通るコードを捕まえられるのもあちらだけです。2つの主張は補完的であり、どちらももう一方を含意しません — 和集合のレーンは最大構成が動くことを証明し、隔離レーンは各部品が単独で動くことを証明します。
--no-default-features とマトリクスは、契約が実在するところにだけ
cargo check -p <crate> --no-default-features は2本目のレーンであって、より良いレーンではありません。これが答えるのは別の問いです:利用者がデフォルトを切ったとき、そのクレートはまだビルドできるか? この問いをゲートにする価値があるのは、デフォルトなしが実際にサポートしている構成であるときだけです — no_std ビルド、default-features = false で使えるとドキュメントされているクレート、利用者が選ぶことを想定しているバックエンドなど。READMEがその約束をしているところにレーンを足してください。打つのが簡単だからという理由で足してはいけません。
同じルールがフィーチャマトリクスにも効きます。cargo hack --each-feature や --feature-powerset は組み合わせを列挙してくれますが、オプショナルなフィーチャが8個あるクレートでは、そのべき集合は誰もサポートしておらず誰も直さない構成を256回コンパイルすることになります。そうではなく、ドキュメント化された契約だけを明示的に列挙してください。一覧はレビュー可能なままになり、各エントリに所有者がいる状態を保てます:
cargo check -p consumer --no-default-features # documented: builds with defaults off
cargo check -p consumer --no-default-features --features rustls-tls # documented: consumer's own rustls backend flag
cargo check -p consumer --no-default-features --features native-tls # documented: consumer's own native-tls flag思いつきで加えた組み合わせが赤くなっても、それはバグ報告ではありません。誰もサポートすると合意していない失敗であり、それを上げるゲートは、赤をノイズとして読むようチームを訓練してしまいます — 行動につながらないアサーションが等しく背負う、偽シグナルのコストです。
cargo tree -e features は説明であって、ゲートではない
レーンが赤くなったら、次の問いはどのパッケージがそのフィーチャを有効にしたのか、です。フィーチャのエッジ付きの cargo tree がそれに答えます。しかもその答えはパッケージ選択によって変わります — だからこそ、これはゲートではなく診断なのです:
cargo tree -p consumer -e features # the feature edges consumer resolves on its own
cargo tree --workspace -e features -i reqwest # inverted: who in the workspace enables each reqwest feature1つ目の呼び出しは隔離レーンがコンパイルするグラフを示し、2つ目はワークスペースビルドがコンパイルする和集合を、反転したエッジ上に有効化しているパッケージの名前とともに示します。この2つを並べて読むことが、失敗の説明になります。どちらも検知ではありません:ツリーは人間が解釈するための出力であり、ゲートは終了コードそのものが判定であるようなコマンドでなければならないからです。cargo tree はトリアージのメモに、隔離された cargo check -p レーンはワークフローに置いてください。
次に読むページ
重いテストの判断ルール — このnextest設定が実行する
#[ignore]理由文字列タグ分類の全体(env-gate:、heavy:、flaky:、verification:、pending-feature:)と、Rustのflakeがどう隔離されるか実行ティア — この移行がRustスイート上で実装可能にするために存在する、リトライ予算
フレイク根本原因カタログ & デフレイキングレシピ — nextestがバイナリをまたいで並列化した瞬間に表面化する、ネイティブスイートのflake原因(ポートレース、暗黙の直列化、共有状態)
CIゲートの最適化 — このセクションの分割実験が1つの波として収まる、ゲート全体のプロトコル。時計に名前を付けること、3本の中央値というサンプルのルール、そして速くなったスイートが依然として同じテストを走らせていることのアイデンティティによる証明