Skill: dedup-rust-tests
File references (@path) in this skill are relative to this directory.
What I do
I have two modes of operation that work together:
-
DEDUP — Identify and remove redundant Rust unit tests where another test already covers the same code paths with the same or equivalent input values. Verified via
cargo llvm-covcoverage comparison (before vs. after). -
GAP-FILL — After dedup, analyze the
llvm-covreport to find untested or under-tested code (specific monomorphizations, trait implementations, feature-gated code), then write targeted unit tests to fill those gaps. Verified via coverage improvement.
Both modes share a common baseline build and coverage measurement workflow.
When to use me
For DEDUP:
- Your Rust test suite has grown organically with multiple test modules covering the same code paths
- An "old-style" inline test module (
#[cfg(test)] mod tests) coexists with newer separate test files (more_tests.rs) that subsume the older tests - A "smoke test" (
it_works) duplicates assertions from multiple focused tests - You inherited a codebase with duplicated test scenarios (e.g.,
test_abs+test_fraction_absboth testing the same method with overlapping inputs) - You want to shrink CI test time without losing coverage
For GAP-FILL:
- Generic types are only tested for one concrete type (e.g.,
Fraction<i32>) — coverage report reveals untested monomorphizations (Fraction<i8>,Fraction<i128>) - Trait implementations (
From<i8>,TryFrom<...>) show 0% coverage - Feature-gated code (
#[cfg(feature = "...")]) is untested - Private methods (
signum(),is_positive()) are uncovered because they can only be tested from within their own module
Do NOT use me when:
- Tests are property-based / generative (quickcheck, proptest) testing invariants — these are intentionally redundant by design
- The test is the only one exercising a particular code path, even if the values seem similar
- The project has no code coverage tooling configured
- You only need to add tests without first checking for redundancy
How I work
Phase 0: Understand the test landscape
# List all Rust source files
Get-ChildItem -Recurse -Filter *.rs
# Find all test annotations and modules
Select-String -Path "src/**/*.rs" -Pattern "#\[test\]|#\[cfg\(test\)\]|#\[quickcheck\]|mod tests"
# Count tests per file
Select-String -Path "src/**/*.rs" -Pattern "^\\s*fn (test_|check_)" | Group-Object -Property Filename
Map the test structure:
- Inline
#[cfg(test)] mod tests { ... }inside source files — private methods accessible here - Standalone
tests/*.rsintegration test files - Feature-gated test modules (
#[cfg(feature = "...")]) - Quickcheck/proptest property tests — never remove these
- Doc-tests (`/// ``` ... ````) — also contribute to coverage
Key questions:
- Are there multiple test modules covering the same code? (e.g.,
fractions.rs::testsvsmore_tests.rs) - Are there "old-style" tests alongside "comprehensive" tests?
- Is there a smoke test (
it_works) that duplicates assertions from focused tests? - What generic type parameters are exercised?
Phase 1: Detect redundancy
Level 1: Same assertions with identical values
Two tests are redundant if they exercise the same function with identical or strictly subset input values:
// test_abs (redundant — subset of test_fraction_abs)
#[test]
fn test_abs() {
assert_eq!(Fraction::new(-3, 4).abs(), Fraction::new(3, 4)); // same as below
}
// test_fraction_abs (keep — covers more)
#[test]
fn test_fraction_abs() {
assert_eq!(Fraction::new(-3, 4).abs(), Fraction::new(3, 4)); // duplicated
assert_eq!(Fraction::new(3, -4).abs(), Fraction::new(3, 4)); // unique
}
Rule: If test A's assertions are all contained within test B, test A is redundant. Remove A.
Level 2: Same code path with equivalent values
Two tests are redundant if they exercise the same function path with equivalent inputs:
// test_mul_div (redundant — subsumed by 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 (keep — covers mul/div AND also 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));
}
Rule: If test A covers only ops X and Y, but test B covers X, Y, and Z, remove A.
Level 3: Duplicate test across modules
The same function is tested in two separate test modules with overlapping scenarios:
// fractions.rs::tests::test_fraction_from_i32_to_i64 (redundant)
#[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 (keep — identical, keep the newer one)
#[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));
}
Rule: When tests live in both inline and separate modules, and they test the same thing identically, remove the duplicate. Generally keep the one in the dedicated test file.
Level 4: Smoke test subsumed by focused tests
// it_works (redundant smoke test — remove)
#[test]
fn it_works() {
assert_eq!(const_gcd(4, -6), 2); // covered by test_const_gcd
assert_eq!(Fraction::new(30, -40), Fraction::new(-3, 4)); // covered by test_fraction_construction
assert_eq!(Fraction::from(30), Fraction::new(30, 1)); // covered by test_fraction_construction
assert_eq!(Fraction::<i32>::default(), Fraction::new(0, 1)); // covered by test_fraction_construction
}
Rule: A smoke test that duplicates assertions from multiple focused tests should be removed. It adds no coverage value.
NEVER remove
- Quickcheck/proptest tests — they run with randomized inputs and verify invariants
- The only test for a specific type — even if it looks similar, it exercises a different monomorphization
- Tests that are the sole exerciser of a code path — check the coverage report
Phase 2: Build baseline (BEFORE removal)
# Generate baseline coverage
cargo llvm-cov --all-features --workspace --summary-only
# Also save detailed report for line-level comparison
cargo llvm-cov --all-features --workspace --lcov --output-path coverage-before.lcov
Record baseline metrics:
- Regions, lines, functions coverage percentages
- Missed regions count
- Total test count:
cargo test --all-features --workspace 2>&1 | Select-String "test result: ok. (\d+) passed"
Phase 3: Remove the redundant tests
Remove each redundant test function from its file using exact edits. After each removal batch, run:
cargo test --all-features --workspace
If compilation fails due to unused imports (e.g., use num_integer::gcd; that was only used by the removed test), clean them up.
Common cleanup tasks:
- Remove
#[cfg(test)] use ...;imports that were only for removed tests - Remove module-level
use num_integer::gcd;if only used by removed smoke test - Remove commented-out test code that references removed functions
Phase 4: Rebuild and collect AFTER-DEDUP metrics
cargo llvm-cov --all-features --workspace --summary-only
cargo llvm-cov --all-features --workspace --lcov --output-path coverage-after-dedup.lcov
Verdict criteria for DEDUP:
| Check | Requirement |
|---|---|
| All tests pass | Must pass |
| Per-file line coverage | No decrease (increase is OK — fewer test lines in binary = higher coverage ratio) |
| Missed regions count | Should decrease or stay same (removing dead test code may increase coverage ratio) |
| No new uncovered code paths | Compare FNDA:0 entries in lcov before/after |
If coverage drops on any file, revert immediately — the test was NOT redundant.
Phase 5: Analyze coverage gaps (GAP-FILL mode)
Parse the lcov file for uncovered functions
The FNDA:0 lines in the lcov file reveal exactly which monomorphizations are untested:
FNDA:0,_RNvMNt...INtB2_8FractionnE7new_rawB4_ # Fraction<type_n>::new_raw — untested
FNDA:0,_RNvMs2_Nt...INtB5_8FractionpE11is_positiveB7_ # Fraction<type_p>::is_positive — untested
FNDA:0,_RNvXs7_Nt...INtB5_8FractionlEINtNt...4FromhE4fromB7_ # From<i8> for Fraction — untested
Common gap patterns in Rust:
| Pattern | Example | What's missing |
|---|---|---|
| Untested monomorphization | Fraction<i8> never instantiated | Add tests with Fraction::<i8>::new(...) |
| Untested trait impl | From<i8> for Fraction<i32> | Add Fraction::from(5i8) test |
| Untested TryFrom impl | TryFrom<Fraction<i64>> for Fraction<i32> | Add Fraction::<i32>::try_from(f_i64) test |
| Private methods | signum(), is_positive() uncovered for i8 | Must test from within same #[cfg(test)] mod tests |
| Feature-gated code | #[cfg(feature = "serde")] untested | Add test with --features serde |
| Neg for specific type | Neg impl for Fraction<i8> | Add -Fraction::<i8>::new(...) |
Decode lcov mangled names
The Rust mangled names in lcov indicate type parameters. Key suffixes to look for:
NtB5_8FractionlE...— Fraction with typel(commonlyi64)NtB5_8FractionnE...— Fraction with typen(commonlyi8)NtB5_8FractionxE...— Fraction with typex(commonlyi128)NtB5_8FractionpE...— Fraction with typep(commonlyi8in some builds)
The exact mapping depends on monomorphization order. Cross-reference with what types are actually used in tests:
i32— almost always covered (used throughout)i64— check if covered (often viaFrom<i32> for Fraction<i64>)i8— rarely covered (needs explicit test)i16— rarely coveredi128— may be covered via quickcheckBigInt— covered via feature-gated tests
Phase 6: Write targeted tests
For untested monomorphizations (i8, i16, etc)
Write a comprehensive test covering construction, setters, arithmetic, comparison, and special values:
#[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));
}
For private methods (must be in same module)
Private methods like signum(), is_positive(), is_negative() can only be tested from within a #[cfg(test)] mod tests block inside the same source file where they're defined:
// In fractions.rs, inside #[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));
}
For untested trait implementations
#[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));
}
Rules for test values
- Use small, safe values for narrow types (e.g.,
i8max is 127 — avoid overflow) - Test both positive and negative values
- Test edge cases: zero denominator (= infinity), zero/zero (= NaN), denominator with opposite sign
- Test identity operations:
-(-x) == x,x * 1 == x,x + 0 == x - Do NOT test private methods from separate test files — they'll fail to compile
Phase 7: Rebuild and collect AFTER-GAP-FILL metrics
cargo test --all-features --workspace
cargo clippy --all-targets --all-features --workspace
cargo llvm-cov --all-features --workspace --summary-only
Phase 8: Report coverage improvement
| Metric | Before | After | Δ |
|-----------------------|----------|----------|----------|
| Test count | N | N' | +Δ |
| fractions.rs regions | 93.76% | 98.45% | +4.69% |
| fractions.rs lines | 92.77% | 98.97% | +6.20% |
| fractions.rs functions| 88.10% | 98.67% | +10.57% |
| Missed regions | 70 | 15 | -55 |
Redundant tests removed: 11
Coverage gaps filled: Fraction<i8>, From<i8>, From<u8>, TryFrom<...> impls
CRITICAL RULES
Rule 1: Coverage is the truth, not test count
A test is redundant only if removing it leaves every file's line coverage unchanged or improved. If coverage drops on any file, the test was covering unique code paths. Revert immediately.
Rule 2: Check FNDA:0 entries, not just percentages
Two runs can show the same percentage but have different uncovered functions. Always compare the exact set of FNDA:0 entries in lcov:
Select-String -Path coverage.lcov -Pattern "^FNDA:0," | ForEach-Object { $_.Line }
These must be the same or fewer after dedup. If a new FNDA:0 appears, something was lost.
Rule 3: Do NOT deduplicate property-based tests
Quickcheck/proptest tests (#[quickcheck], proptest!) are intentionally redundant by design. They verify invariants with randomized inputs. Never remove these.
Rule 4: Do NOT deduplicate tests with different types
test_fraction_arithmetic for Fraction<i32> and a similar test for Fraction<i64> exercise different monomorphizations. In Rust, generic code is monomorphized per concrete type — each type produces separate compiled functions. These are NOT redundant.
Rule 5: Private methods need in-module testing
If coverage shows signum(), is_positive(), is_negative() uncovered for a specific type, these must be tested from within the same source file's #[cfg(test)] mod tests { ... } block. They cannot be tested from separate test files.
Rule 6: Clean up imports after removal
After removing tests, check for unused imports:
cargo clippy --all-targets --all-features --workspace
Remove imports that were only used by the deleted tests. This includes:
- Crate-level
usestatements that become dead - Module-level
useimports in test modules #[cfg(test)] use ...;that become unnecessary
Rule 7: Match the existing test style
Before writing new tests, study 2-3 existing tests to match:
- Type annotation style (explicit
Fraction::<i32>::new(...)vs implicitFraction::new(1i32, 2)) - Assertion macro preference (
assert_eq!vsassert!) - Whether to use
new_rawornewfor special values - Test naming convention (
test_<type>_<operation>vs<operation>_<type>)
Rule 8: Test all branches
For each method with conditional logic:
if self.is_nan() || other.is_nan()→ test NaN inputsif self.is_infinite() && other.is_infinite()→ test both infiniteif self.is_zero() && other.is_zero()→ test zero/zeroif self.denom == other.denom→ test same-denominator optimization- Normal path → test with regular values
Rule 9: Prioritize high-impact gaps
- Untested monomorphizations (e.g.,
Fraction<i8>wherei32/i64are covered) → highest priority (many functions instantly covered) - Untested trait implementations (e.g.,
From<i8>) → medium priority - Private methods (e.g.,
signumfor specific types) → lower priority, must be in-module - Feature-gated code → test with the feature flag
Rule 10: doc-tests count too
Doc-tests (/// ``` ... ````) in documentation comments are executed by cargo test` and contribute to coverage. They exercise the public API with the exact code examples shown to users. Consider this when evaluating coverage gaps.
Common patterns
Pattern A: Old tests subsumed by comprehensive tests
A crate starts with simple inline tests (test_add_sub, test_mul_div), then later someone adds comprehensive tests (test_fraction_arithmetic covering all four ops plus negation and reciprocal). The old tests are redundant.
Detection: Read both test functions. If the newer one covers all ops the older one does plus more, the older one is redundant.
Fix: Remove the older, narrower tests. Keep the comprehensive one.
Pattern B: Duplicate test across inline and separate test modules
The same operation is tested in both src/lib.rs::#[cfg(test)] mod tests and tests/more_tests.rs with identical assertions.
Detection: grep for the same function call and assertion values across both files.
Fix: Remove the duplicate. Prefer the separate test file if it's more comprehensive. Keep the inline test only if it tests private methods.
Pattern C: Untested generic monomorphization
Fraction<T> is tested thoroughly for T = i32 and T = i64, but T = i8 and T = i128 are never instantiated. The lcov report shows 0% for all Fraction<i8> methods.
Detection: FNDA:0 entries with type suffix n or p in mangled names.
Fix: Add a focused test_i8_* test suite covering construction, setters, arithmetic, comparison, and special values.
Pattern D: Untested From/TryFrom impls
The crate has impl From<i8> for Fraction<i32> and impl From<u8> for Fraction<i32> but neither is tested.
Detection: grep for impl From< or impl TryFrom< in source, cross-reference against test coverage.
Fix: Add simple Fraction::from(value) tests.
Pattern E: Private methods uncovered
signum(), is_positive(), is_negative() are fn (not pub fn). They can only be tested from within the same module's test block. An external test file (more_tests.rs) cannot call them.
Detection: Private method has FNDA:0 but no test file can access it.
Fix: Add the test inside the module's own #[cfg(test)] mod tests { ... } block.
Troubleshooting
Problem: cargo llvm-cov not found
cargo install cargo-llvm-cov
Problem: "no coverage data" for feature-gated code
Make sure to pass the feature flag:
cargo llvm-cov --features "num-bigint,serde" --summary-only
Problem: Private method "is private" error in external test
The method is fn not pub fn. Move the test into the source file's #[cfg(test)] mod tests { ... } block.
Problem: lcov file is too large to parse
Use --summary-only for quick checks, and grep the lcov for specific patterns:
Select-String -Path coverage.lcov -Pattern "^FNDA:0," -Context 0,1
Problem: Coverage percentage doesn't change after adding tests
The new test might not be exercising the specific monomorphization you intended. Check:
- Are you using the correct concrete type? (
Fraction::<i8>::new(...)notFraction::new(1, 2)which defaults to i32) - Is the code path actually reachable with your test values?
- Is the method public? Private methods need in-module testing.