CommunityCoding & Developmentgithub.com

luk036/dedup-rust-tests

Find and remove redundant Rust unit tests, then fill coverage gaps with targeted tests - all verified with before/after cargo llvm-cov comparison.

What is dedup-rust-tests?

dedup-rust-tests is a OpenCode agent skill that find and remove redundant Rust unit tests, then fill coverage gaps with targeted tests - all verified with before/after cargo llvm-cov comparison.

Works with~Claude Code~Codex CLI~Cursor✓OpenCode
npx skills add https://github.com/luk036/agent-skills/tree/HEAD/skills/dedup-rust-tests

Installed? Explore more Coding & Development skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

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:

  1. 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-cov coverage comparison (before vs. after).

  2. GAP-FILL — After dedup, analyze the llvm-cov report 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_abs both 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/*.rs integration 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::tests vs more_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:

CheckRequirement
All tests passMust pass
Per-file line coverageNo decrease (increase is OK — fewer test lines in binary = higher coverage ratio)
Missed regions countShould decrease or stay same (removing dead test code may increase coverage ratio)
No new uncovered code pathsCompare 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:

PatternExampleWhat's missing
Untested monomorphizationFraction<i8> never instantiatedAdd tests with Fraction::<i8>::new(...)
Untested trait implFrom<i8> for Fraction<i32>Add Fraction::from(5i8) test
Untested TryFrom implTryFrom<Fraction<i64>> for Fraction<i32>Add Fraction::<i32>::try_from(f_i64) test
Private methodssignum(), is_positive() uncovered for i8Must test from within same #[cfg(test)] mod tests
Feature-gated code#[cfg(feature = "serde")] untestedAdd test with --features serde
Neg for specific typeNeg 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 type l (commonly i64)
  • NtB5_8FractionnE... — Fraction with type n (commonly i8)
  • NtB5_8FractionxE... — Fraction with type x (commonly i128)
  • NtB5_8FractionpE... — Fraction with type p (commonly i8 in 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 via From<i32> for Fraction<i64>)
  • i8 — rarely covered (needs explicit test)
  • i16 — rarely covered
  • i128 — may be covered via quickcheck
  • BigInt — 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., i8 max 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 use statements that become dead
  • Module-level use imports 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 implicit Fraction::new(1i32, 2))
  • Assertion macro preference (assert_eq! vs assert!)
  • Whether to use new_raw or new for 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 inputs
  • if self.is_infinite() && other.is_infinite() → test both infinite
  • if self.is_zero() && other.is_zero() → test zero/zero
  • if self.denom == other.denom → test same-denominator optimization
  • Normal path → test with regular values

Rule 9: Prioritize high-impact gaps

  1. Untested monomorphizations (e.g., Fraction<i8> where i32/i64 are covered) → highest priority (many functions instantly covered)
  2. Untested trait implementations (e.g., From<i8>) → medium priority
  3. Private methods (e.g., signum for specific types) → lower priority, must be in-module
  4. 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(...) not Fraction::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.

Individual skills in this repo

This repo contains 3 individual skills — each has its own dedicated page.

Related Skills