스킬: dedup-rust-tests
이 스킬의 파일 참조(@path)는 이 디렉토리를 기준으로 합니다.
내가 하는 일
저는 함께 작동하는 두 가지 모드를 가지고 있습니다:
-
DEDUP — 다른 테스트가 이미 동일한 코드 경로를 동일하거나 동등한 입력 값으로 커버하고 있는 중복된 Rust 유닛 테스트를 식별하고 제거합니다.
cargo llvm-cov커버리지 비교(전후)를 통해 검증합니다. -
GAP-FILL — 중복 제거 후,
llvm-cov보고서를 분석하여 테스트되지 않았거나 부족하게 테스트된 코드(특정 단형화, 트레이트 구현, 기능 플래그가 적용된 코드)를 찾고, 해당 격차를 메우기 위해 대상이 지정된 테스트를 작성합니다. 커버리지 개선을 통해 검증합니다.
두 모드 모두 공통 기본 빌드 및 커버리지 측정 워크플로우를 공유합니다.
나를 사용해야 하는 경우
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>)가 드러납니다. - 트레이트 구현(
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 (유지 — 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: 모듈 간 중복 테스트
동일한 함수가 중복되는 시나리오로 두 개의 별도 테스트 모듈에서 테스트됩니다:
// 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 전후의 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_ # From<i8> for Fraction — 테스트되지 않음
Rust의 일반적인 격차 패턴:
| 패턴 | 예시 | 누락된 것 |
|---|---|---|
| 테스트되지 않은 단형화 | Fraction<i8>가 인스턴스화되지 않음 | Fraction::<i8>::new(...)로 테스트 추가 |
| 테스트되지 않은 트레이트 구현 | From<i8> for Fraction<i32> | 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 | Neg 구현 for Fraction<i8> | -Fraction::<i8>::new(...) 추가 |
lcv 맹글된 이름 디코딩
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));
}
테스트되지 않은 트레이트 구현의 경우
#[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 — 오버플로우 방지)의 경우 작고 안전한 값을 사용합니다. - 양수와 음수 값을 모두 테스트합니다.
- 가장자리 경우 테스트: 분모 0(= 무한대), 0/0(= 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<...>