技能:dedup-rust-tests
本技能中的文件引用(@path)均相对于此目录。
我的作用
我有两种协同工作的模式:
-
DEDUP — 识别并移除重复的 Rust 单元测试(即其他测试已使用相同或等效输入值覆盖同一代码路径的测试)。通过
cargo llvm-cov的覆盖率对比(删除前 vs. 删除后)进行验证。 -
GAP-FILL — 去重后,分析
llvm-cov报告,查找未测试或测试不足的代码(特定单态化、trait 实现、特性门控代码),然后编写针对性单元测试以填补这些缺口。通过覆盖率提升情况进行验证。
两种模式共享一个通用的基线构建和覆盖率衡量工作流程。
何时使用我
用于 DEDUP:
- 您的 Rust 测试套件是逐步增长的,存在多个测试模块覆盖相同代码路径的情况
- 一个“旧式”内联测试模块(
#[cfg(test)] mod tests)与包含更广泛测试的新测试文件(如more_tests.rs)并存 - “冒烟测试”(如
it_works)复制了多个针对性测试中的断言 - 您接手了一个含有重复测试场景的代码库(例如,
test_abs和test_fraction_abs都使用重叠的输入测试同一个方法) - 您希望在保持覆盖率的前提下缩短 CI 测试时间
用于 GAP-FILL:
- 泛型类型仅针对一种具体类型测试(例如,
Fraction<i32>),而覆盖率报告揭示了未被测试的单态化(如Fraction<i8>、Fraction<i128>) - Trait 实现(如
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 属性测试 —— 切勿移除
- 文档测试(`/// ``` ... ````)—— 也有助于覆盖率
关键问题:
- 是否有多个测试模块覆盖相同代码?(例如,
fractions.rs::testsvsmore_tests.rs) - 是否存在“旧式”测试与“全面”测试并存?
- 是否存在一个冒烟测试(
it_works)复制了针对性测试中的断言? - 使用了哪些泛型类型参数?
阶段 1:检测冗余
级别 1:相同断言与相同值
如果两个测试使用相同或严格来说是子集的输入值测试同一个函数,则它们是冗余的:
// 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:相同代码路径与等效值
如果两个测试使用等效输入执行同一个函数路径,则它们是冗余的:
// 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(保留——覆盖了乘/除以及加/减/取反/倒数)
#[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:跨模块重复测试
同一个函数在两个独立的测试模块中进行了测试,且包含重叠的场景:
// 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 的验证标准:
| 检查项 | 要求 |
|---|---|
| 所有测试通过 | 必须通过 |
| 每个文件的行覆盖率 | 不能下降(上升是可以的——二进制文件中测试行数减少 = 覆盖率比率提高) |
| 未覆盖区域计数 | 应减少或保持不变(移除死测试代码可能会提高覆盖率比率) |
| 无新的未覆盖代码路径 | 比较 lcov 文件中 before/after 的 FNDA:0 条目 |
如果任何文件的覆盖率下降,立即回退——该测试不是冗余的。
阶段 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(...) 添加测试 |
| 未测试的 trait 实现 | Fraction<i32> 的 From<i8> | 添加 Fraction::from(5i8) 测试 |
| 未测试的 TryFrom 实现 | TryFrom<Fraction<i64>> for Fraction<i32> | 添加 Fraction::<i32>::try_from(f_i64) 测试 |
| 私有方法 | signum(), is_positive() 对于 i8 未覆盖 | 必须从同一个 #[cfg(test)] mod tests 内进行测试 |
| 特性门控代码 | #[cfg(feature = "serde")] 未测试 | 使用 --features serde 添加测试 |
| 特定类型的取反 | Neg 为 Fraction<i8> 实现 | 添加 -Fraction::<i8>::new(...) |
解码 lcov 中的混淆名称
lcov 中 Rust 的混淆名称表示了类型参数。需要注意的关键后缀:
NtB5_8FractionlE...— 带有类型l(通常是i64)的 FractionNtB5_8FractionnE...— 带有类型n(通常是i8)的 FractionNtB5_8FractionxE...— 带有类型x(通常是i128)的 FractionNtB5_8FractionpE...— 带有类型p(某些构建中通常是i8)的 Fraction
确切的映射取决于单态化顺序。请与测试中实际使用的类型进行交叉引用:
i32— 几乎总是已被覆盖(广泛使用)i64— 检查是否已被覆盖(通常通过From<i32> for Fraction<i64>)i8— 很少被覆盖(需要显式测试)i16— 很少被覆盖i128— 可能通过 quickcheck 被覆盖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));
}
针对未测试的 trait 实现
#[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<