スキル: dedup-rust-tests
このスキル内のファイル参照(@path)は、このディレクトリからの相対パスです。
私の役割
私は連携して動作する2つのモードを持っています。
-
DEDUP(重複検出・削除) — 別のテストが同じコードパスを同一または同等の入力値で既にカバーしている、Rustの単体テストを特定・削除します。
cargo llvm-covのカバレッジ比較(before vs after)で検証します。 -
GAP-FILL(カバレッジギャップ埋め) — DEDUPの後、
llvm-covレポートを分析して未テストまたはテストが不十分なコード(特定の単相化、トレイト実装、フィーチャーゲートされたコード)を特定し、それらのギャップを埋めるための対象を絞ったテストを作成します。カバレッジの改善で検証します。
両モードとも共通のベースライン構築とカバレッジ測定のワークフローを共有します。
使用するタイミング
DEDUP用:
- Rustのテストスイートが有機的に成長し、複数のテストモジュールが同じコードパスをカバーしている場合
- 「旧式」のインラインテストモジュール(
#[cfg(test)] mod tests)が、新しい別のテストファイル(more_tests.rs)と共存し、後者が前者を包含している場合 - 「スモークテスト」(
it_works)が複数の焦点を絞ったテストのアサーションを重複している場合 - 重複したテストシナリオを含むコードベースを引き継いだ場合(例:
test_abs+test_fraction_absが同じメソッドを重複する入力でテスト) - CIテスト時間をカバレッジを失わずに短縮したい場合
GAP-FILL用:
- ジェネリック型が1つの具象型でのみテストされている場合(例:
Fraction<i32>)— カバレッジレポートが未テストの単相化(Fraction<i8>,Fraction<i128>)を明らかにする場合 - トレイト実装(
From<i8>,TryFrom<...>)のカバレッジが0%の場合 - フィーチャーゲートされたコード(
#[cfg(feature = "...")])が未テストの場合 - プライベートメソッド(
signum(),is_positive())が自身のモジュール内からのみテスト可能なためにカバレッジがない場合
使用すべきでないケース:
- テストがプロパティベース/生成テスト(quickcheck、proptest)で不変条件をテストしている場合 — これらは設計上意図的に冗長です
- テストが特定のコードパスを唯一実行するものである場合(値が似ていても)
- プロジェクトにコードカバレッジツールが設定されていない場合
- 冗長性を最初に確認せずにテストを追加するだけの場合
動作の仕組み
フェーズ0: テスト環境の理解
# すべてのRustソースファイルを一覧表示
Get-ChildItem -Recurse -Filter *.rs
# すべてのテストアノテーションとモジュールを検索
Select-String -Path "src/**/*.rs" -Pattern "#\[test\]|#\[cfg\(test\)\]|#\[quickcheck\]|mod tests"
# ファイルごとのテスト数をカウント
Select-String -Path "src/**/*.rs" -Pattern "^\\s*fn (test_|check_)" | Group-Object -Property Filename
テスト構造をマッピングします:
- ソースファイル内のインライン
#[cfg(test)] mod tests { ... }— プライベートメソッドにアクセス可能 - スタンドアロンの
tests/*.rs統合テストファイル - フィーチャーゲートされたテストモジュール(
#[cfg(feature = "...")]) - Quickcheck/proptest プロパティテスト — これらは決して削除しない
- Doc-tests(`/// ``` ... ````)— これもカバレッジに寄与します
重要な質問:
- 同じコードをカバーする複数のテストモジュールがありますか?(例:
fractions.rs::testsとmore_tests.rs) - 「旧式」のテストと「包括的な」テストが共存していますか?
- 個別のテストのアサーションを重複するスモークテスト(
it_works)がありますか? - どのジェネリック型パラメータが実行されていますか?
フェーズ1: 冗長性の検出
レベル1: 同一値による同一アサーション
2つのテストが、同一または厳密にサブセットの入力値で同じ関数を実行する場合、冗長です:
// test_abs(冗長 — test_fraction_absのサブセット)
#[test]
fn test_abs() {
assert_eq!(Fraction::new(-3, 4).abs(), Fraction::new(3, 4)); // 下記と同じ
}
// test_fraction_abs(保持 — より多くをカバー)
#[test]
fn test_fraction_abs() {
assert_eq!(Fraction::new(-3, 4).abs(), Fraction::new(3, 4)); // 重複
assert_eq!(Fraction::new(3, -4).abs(), Fraction::new(3, 4)); // 一意
}
ルール: テストAのアサーションがすべてテストBに含まれている場合、テストAは冗長です。Aを削除します。
レベル2: 同等の値による同じコードパス
2つのテストが、同等の入力で同じ関数パスを実行する場合、冗長です:
// test_mul_div(冗長 — test_fraction_arithmeticに包含される)
#[test]
fn test_mul_div() {
assert_eq!(Fraction::new(3, 4) * Fraction::new(5, 6), Fraction::new(5, 8));
assert_eq!(Fraction::new(3, 4) / Fraction::new(5, 6), Fraction::new(9, 10));
}
// test_fraction_arithmetic(保持 — mul/divだけでなくadd/sub/neg/reciprocalもカバー)
#[test]
fn test_fraction_arithmetic() {
assert_eq!(Fraction::new(1, 2) + Fraction::new(1, 3), Fraction::new(5, 6));
assert_eq!(Fraction::new(1, 2) - Fraction::new(1, 3), Fraction::new(1, 6));
assert_eq!(Fraction::new(1, 2) * Fraction::new(1, 3), Fraction::new(1, 6));
assert_eq!(Fraction::new(1, 2) / Fraction::new(1, 3), Fraction::new(3, 2));
assert_eq!(-Fraction::new(1, 2), Fraction::new(-1, 2));
assert_eq!(Fraction::new(1, 2).inv(), Fraction::new(2, 1));
}
ルール: テストAが操作XとYのみをカバーするが、テストBがX、Y、かつZをカバーする場合、Aを削除します。
レベル3: モジュール間でのテストの重複
同じ関数が2つの別々のテストモジュールで重複するシナリオでテストされています:
// fractions.rs::tests::test_fraction_from_i32_to_i64(冗長)
#[test]
fn test_fraction_from_i32_to_i64() {
let frac: Fraction<i64> = Fraction::from(3_i32);
assert_eq!(frac, Fraction::new(3, 1));
}
// more_tests.rs::test_from_i32_to_i64(保持 — 同一、新しい方を保持)
#[test]
fn test_from_i32_to_i64() {
let f_i32 = 3;
let f_i64 = Fraction::<i64>::from(f_i32);
assert_eq!(f_i64, Fraction::new(3i64, 1i64));
}
ルール: インラインモジュールと別ファイルモジュールの両方にテストが存在し、それらが同一のものをテストしている場合、重複を削除します。一般的には専用のテストファイルにあるものを保持します。
レベル4: スモークテストが個別テストに包含される場合
// it_works(冗長スモークテスト — 削除)
#[test]
fn it_works() {
assert_eq!(const_gcd(4, -6), 2); // test_const_gcdでカバー済み
assert_eq!(Fraction::new(30, -40), Fraction::new(-3, 4)); // test_fraction_constructionでカバー済み
assert_eq!(Fraction::from(30), Fraction::new(30, 1)); // test_fraction_constructionでカバー済み
assert_eq!(Fraction::<i32>::default(), Fraction::new(0, 1)); // test_fraction_constructionでカバー済み
}
ルール: 複数の個別テストのアサーションを重複するスモークテストは削除する必要があります。カバレッジに価値を追加しません。
絶対に削除しないもの
- Quickcheck/proptestテスト — ランダム化された入力で実行され、不変条件を検証します
- 特定の型の唯一のテスト — たとえ似ていても、異なる単相化を実行しています
- コードパスの唯一の実行元であるテスト — カバレッジレポートを確認してください
フェーズ2: ベースラインの構築(削除前)
# ベースラインカバレッジを生成
cargo llvm-cov --all-features --workspace --summary-only
# 行レベルの比較のために詳細レポートも保存
cargo llvm-cov --all-features --workspace --lcov --output-path coverage-before.lcov
ベースラインメトリクスを記録:
- リージョン、行、関数のカバレッジ率
- ミスされたリージョンの数
- 総テスト数:
cargo test --all-features --workspace 2>&1 | Select-String "test result: ok. (\d+) passed"
フェーズ3: 冗長テストの削除
各冗長テスト関数をファイルから正確な編集で削除します。削除バッチごとに、以下を実行します:
cargo test --all-features --workspace
コンパイルが未使用のインポート(例:削除されたテストでのみ使用されていた use num_integer::gcd;)のために失敗した場合は、それらをクリーンアップします。
一般的なクリーンアップタスク:
- 削除されたテストでのみ使用されていた
#[cfg(test)] use ...;インポートを削除する - 削除されたスモークテストでのみ使用されていたモジュールレベルの
use num_integer::gcd;を削除する - 削除された関数を参照するコメントアウトされたテストコードを削除する
フェーズ4: 再構築と削除後メトリクスの収集
cargo llvm-cov --all-features --workspace --summary-only
cargo llvm-cov --all-features --workspace --lcov --output-path coverage-after-dedup.lcov
DEDUPの判定基準:
| チェック項目 | 要件 |
|---|---|
| すべてのテストが通過 | 必須 |
| ファイルごとの行カバレッジ | 低下しない(増加はOK — バイナリ内のテスト行が減るとカバレッジ比率が上がる) |
| ミスされたリージョン数 | 減少または同じ(デッドテストコードの削除はカバレッジ比率を上げる可能性がある) |
| 新しい未カバレッジコードパスなし | lcovの FNDA:0 エントリをbefore/afterで比較 |
どのファイルでもカバレッジが低下した場合、すぐに元に戻してください — そのテストは冗長ではありませんでした。
フェーズ5: カバレッジギャップの分析(GAP-FILLモード)
lcovファイルから未カバー関数を解析
lcovファイル内の FNDA:0 行は、正確にどの単相化が未テストかを示します:
FNDA:0,_RNvMNt...INtB2_8FractionnE7new_rawB4_ # Fraction<type_n>::new_raw — 未テスト
FNDA:0,_RNvMs2_Nt...INtB5_8FractionpE11is_positiveB7_ # Fraction<type_p>::is_positive — 未テスト
FNDA:0,_RNvXs7_Nt...INtB5_8FractionlEINtNt...4FromhE4fromB7_ # Fractionに対するFrom<i8> — 未テスト
Rustにおける一般的なギャップパターン:
| パターン | 例 | 不足しているもの |
|---|---|---|
| 未テストの単相化 | Fraction<i8> がインスタンス化されていない | Fraction::<i8>::new(...) を使ったテストを追加 |
| 未テストのトレイト実装 | Fraction<i32> に対する From<i8> | Fraction::from(5i8) テストを追加 |
| 未テストのTryFrom実装 | Fraction<i32> に対する TryFrom<Fraction<i64>> | Fraction::<i32>::try_from(f_i64) テストを追加 |
| プライベートメソッド | signum(), is_positive() がi8で未カバレッジ | 同じ #[cfg(test)] mod tests 内からテストする必要がある |
| フィーチャーゲートされたコード | #[cfg(feature = "serde")] が未テスト | --features serde でテストを追加 |
| 特定の型のNeg | Fraction<i8> の Neg 実装 | -Fraction::<i8>::new(...) を追加 |
lcovのマングル名を解読
lcov内のRustのマングル名は型パラメータを示します。注目すべき主要なサフィックス:
NtB5_8FractionlE...— 型lの Fraction(一般的にはi64)NtB5_8FractionnE...— 型nの Fraction(一般的にはi8)NtB5_8FractionxE...— 型xの Fraction(一般的にはi128)NtB5_8FractionpE...— 型pの Fraction(ビルドによっては一般的にi8)
正確なマッピングは単相化の順序に依存します。テストで実際に使用されている型と相互参照します:
i32— ほぼ常にカバーされている(全体で使用)i64— カバーされているか確認(多くの場合Fraction<i64>に対するFrom<i32>経由)i8— めったにカバーされない(明示的なテストが必要)i16— めったにカバーされないi128— クイックチェック経由でカバーされる可能性ありBigInt— フィーチャーゲートされたテストでカバーされる
フェーズ6: 対象を絞ったテストの作成
未テストの単相化(i8、i16など)用
構築、セッター、算術、比較、特殊値をカバーする包括的なテストを作成します:
#[test]
fn test_i8_construction() {
let f = Fraction::new(1i8, 2i8);
assert_eq!(f, Fraction::new(1i8, 2));
let f_raw = Fraction::new_raw(3i8, 4i8);
assert_eq!(*f_raw.numer(), 3i8);
assert_eq!(*f_raw.denom(), 4i8);
}
#[test]
fn test_i8_setters() {
let mut f: Fraction<i8> = Fraction::new(1, 2);
f.set_zero();
assert!(f.is_zero());
f.set_one();
assert!(f.is_one());
f.set_infinite();
assert!(f.is_infinite());
f.set_nan();
assert!(f.is_nan());
}
#[test]
fn test_i8_arithmetic() {
let f1: Fraction<i8> = Fraction::new(1, 2);
let f2: Fraction<i8> = Fraction::new(1, 3);
assert_eq!(f1 + f2, Fraction::new(5i8, 6));
assert_eq!(f1 - f2, Fraction::new(1i8, 6));
assert_eq!(f1 * f2, Fraction::new(1i8, 6));
assert_eq!(f1 / f2, Fraction::new(3i8, 2));
assert_eq!(-f1, Fraction::new(-1i8, 2));
assert_eq!(f1.abs(), Fraction::new(1i8, 2));
}
プライベートメソッド用(同一モジュール内に必須)
プライベートメソッド(例:signum(), is_positive(), is_negative())は、それらが定義されている同じソースファイル内の #[cfg(test)] mod tests ブロックからのみテストできます:
// fractions.rs内、#[cfg(test)] mod tests の中:
#[test]
fn test_i8_signum_and_predicates() {
let pos: Fraction<i8> = Fraction::new(3, 4);
assert!(pos.is_positive());
assert!(!pos.is_negative());
assert_eq!(pos.signum(), Fraction::new(1i8, 1));
let neg: Fraction<i8> = Fraction::new(-3, 4);
assert!(neg.is_negative());
assert_eq!(neg.signum(), Fraction::new(-1i8, 1));
let zero: Fraction<i8> = Fraction::new(0, 1);
assert_eq!(zero.signum(), Fraction::new(0i8, 1));
}
未テストのトレイト実装用
#[test]
fn test_from_i8_to_i32() {
let f: Fraction<i32> = Fraction::from(5i8);
assert_eq!(f, Fraction::new(5i32, 1));
}
#[test]
fn test_try_from_i64_to_i32() {
use core::convert::TryFrom;
let f_i64 = Fraction::new(5i64, 1i64);
let f_i32 = Fraction::<i32>::try_from(f_i64).unwrap();
assert_eq!(f_i32, Fraction::new(5i32, 1));
}
テスト値のルール
- 狭い型には小さく安全な値を使用(例:
i8の最大値は127 — オーバーフローを避ける) - 正の値と負の値の両方をテストする
- エッジケースをテストする:ゼロ分母(= 無限大)、ゼロ/ゼロ(= NaN)、符号が反対の分母
- 恒等操作をテストする:
-(-x) == x、x * 1 == x、x + 0 == x - 別のテストファイルからプライベートメソッドをテストしない — コンパイルに失敗します
フェーズ7: 再構築とギャップ埋め後メトリクスの収集
cargo test --all-features --workspace
cargo clippy --all-targets --all-features --workspace
cargo llvm-cov --all-features --workspace --summary-only
フェーズ8: カバレッジ改善の報告
| メトリクス | 改善前 | 改善後 | Δ |
|-----------------------|----------|----------|----------|
| テスト数 | N | N' | +Δ |
| fractions.rs リージョン | 93.76% | 98.45% | +4.69% |
| fractions.rs 行 | 92.77% | 98.97% | +6.20% |
| fractions.rs 関数 | 88.10% | 98.67% | +10.57% |
| ミスされたリージョン数 | 70 | 15 | -55 |
削除された冗長テスト数: 11
埋められたカバレッジギャップ: Fraction<i8>, From<i8>, From<u8>, TryFrom<...