이 실험을 진행한 이유
코딩 에이전트는 당신이 커피를 다 마시기 전에 백 개의 통과하는 테스트를 작성할 수 있습니다. 테스트 개수는 늘어나고, 커버리지 리포트는 초록색으로 변하며, 풀 리퀘스트는 철저해 보입니다. 하지만 아무도 알려주지 않는 것은 이 테스트들 중 얼마나 많은 것이 다른 것들과 다른 무언가를 검사하고 있는지입니다.
저는 중복된 Vitest 테스트를 찾는 정적 분석기인 vitest-dedup을 유지 관리하고 있기 때문에, 이 부분에 명백한 관심이 있습니다. 이것이 제가 추측하는 대신 측정하기를 원했던 이유이기도 합니다. 이 문제의 어려운 점은 비슷해 보이는 테스트를 찾는 것이 아닙니다. 비슷한 테스트를 동등한 것으로 오인하지 않는 것입니다. 왜냐하면 해결책이 정반대이기 때문입니다. 중복된 것을 삭제할 수는 있지만, 실제로는 다른 코드 경로를 커버하는 '유사한' 테스트를 삭제하는 것은 스위트가 초록색을 유지하는 동안 커버리지를 잃게 만듭니다.
그래서 저는 작은 실험을 설정했습니다. 코딩 에이전트가 두 개의 작은 모듈에 대해 두 번의 별도 세션에서 139개의 테스트를 작성했습니다. 저는 모든 의심스러운 쌍을 수동으로 분류하고, 세 가지 감지기(naive 토큰 기반 복제본 검출기, vitest-dedup 0.4.0, 그리고 제가 10월 17일에 출시한 vitest-dedup 0.5.0)를 비교했습니다. 모든 것은 재현 가능하며, 명령어는 마지막에 있습니다. 답은 제가 예상했던 것이 아니었습니다.
두 개의 테스트가 '같을' 수 있는 네 가지 방법
무엇이든 세기 전에, 우리가 무엇을 세고 있는지 이름을 붙이는 것이 도움이 됩니다. 두 개의 테스트는 네 가지 방식으로 관련될 수 있으며, 각각 다른 조치가 필요합니다.
첫 번째는 동일한(identical) 테스트입니다. 같은 본문, 같은 컨텍스트이며, 제목이 다르거나 변수 이름만 바뀐 경우일 수 있습니다. 복사본 하나를 제거할 수 있습니다.
두 번째는 근접 중복(near-duplicate)입니다. 본문은 다르지만, 도구가 동등하다고 증명할 수 없는 방식으로 동일한 동작을 수행합니다. 누군가 살펴봐야 합니다.
세 번째는 매개변수화 후보(parameterization candidate)입니다. 구조는 같지만 리터럴 값만 다른 경우입니다. 두 경우 모두 필요할 가능성이 높으며, 하나의 test.each 테이블에 속해야 합니다. 하나를 삭제하면 커버리지를 잃게 됩니다.
네 번째는 문제를 일으키는 경우입니다: 비슷해 보이거나 심지어 텍스트적으로 동일하지만 다른 동작을 테스트하는 케이스입니다. 이러한 케이스를 플래그하는 도구는 단순히 노이즈가 많은 것이 아닙니다. 실제 검증(check)을 제거하는 삭제를 제안하고 있다는 의미입니다.
vitest-dedup은 이 분할을 출력에 인코딩합니다. 모든 발견 사항에는 판결이 담겨 있습니다: redundant (복사본 하나만 유지 가능), consolidatable (테이블로 병합, 모든 행 유지), 또는 검토 요청입니다. 실험에서 보여주듯이, 이러한 판결은 발견된 개수보다 더 중요합니다.
단순한 감지기가 틀리는 경우
클론(clone) 탐지의 고전적인 접근 방식은 토큰 단위로 작동합니다. Type-1 클론은 토큰 대 토큰으로 동일하며; Type-2 클론은 식별자(identifiers)와 리터럴(literals)을 플레이스홀더로 대체한 후 동일합니다. 저는 테스트 콜백에 대해 이 두 가지를 모두 수행하는 60줄짜리 감지기를 작성했으며, 제목은 무시했습니다. 이는 일반적인 클론 탐지기나 간단한 스크립트를 테스트 폴더에 적용했을 때 얻게 될 결과의 공정한 대용품입니다.
이 섹션의 예제들은 '유사함'과 '동등함'을 분리하기 위해 만들어진 수작업 트랩(traps)들입니다. 에이전트가 작성한 코퍼스(corpus)는 나중에 다룹니다.
여기에 첫 번째 트랩이 있습니다. 텍스트가 동일한 두 개의 테스트 케이스:
describe('frozen clock', () => {
const clock = { now: () => 0 }
...
첫 번째 테스트는 시간이 멈췄을 때 아무것도 재충전되지 않음을 확인합니다. 두 번째 테스트는 엄청난 재충전이 용량에 맞춰 제한됨을 확인합니다. 둘 다 완전히 다른 이유로 3을 단언(assert)합니다. 단순한 감지기는 이들을 정확히 중복된 것으로 보고합니다. 본문은 동일한 텍스트이지만, clock은 각각에서 다른 바인딩이며, 그 바인딩 자체가 테스트입니다.
훅(hooks)에서도 같은 일이 발생합니다. beforeEach 내에서 new Cart(0.1)을 생성하는 블록 아래의 it과, new Cart()를 생성하는 블록 아래의 it은 동일한 본문을 가지고 있지만 서로 다른 테스트입니다. Type-1도 이들을 중복으로 플래그합니다.
vitest-dedup 0.5.0은 어느 쌍도 보고하지 않습니다. 캡처된 변수는 사용되는 위치에서 비교에 참여하며, 초기 값이 우연히 일치하더라도 어떤 감싸는(enclosing) 변수에서 왔는지 추적합니다. Hooks, mocks 및 fixtures는 테스트의 컨텍스트 일부이므로, 서로 다른 컨텍스트 하의 동일한 본문은 동등하다고 비교되지 않습니다.
The naive detector flags the captured-clock and hook pairs as exact duplicates; vitest-dedup 0.5.0 reports only the renamed duplicate
vitest-dedup이 보고하는 유일한 쌍은 실제 중복입니다: 한 테스트에서 cart를 basket으로 이름만 바꾼 동일한 테스트.
반대의 실패 사례도 마찬가지로 흔합니다. 에이전트들이 무언가를 이름을 바꾸기 때문입니다. 여기는 동작(behavior)을 제외하고 모든 것이 변경된 쌍입니다:
it('emits one event per order', () => {
const events: string[] = []
class SpyBus {
...
타입-1 비교(type-1 comparison)는 이를 완전히 놓칩니다. vitest-dedup 0.4.0은 동일한 동작 시퀀스를 보았지만, 로컬 클래스가 동등함을 증명할 수 없었기 때문에 검토를 위해 near-duplicate-test 경고를 기록했습니다. 0.5.0은 로컬 클래스(local classes): 필드, 메서드, 생성자, private 이름 및 각 인스턴스가 캡처하는 내용을 모델링합니다. 일관된 이름 변경은 비교 가능하게 유지되므로, 판결은 증명된 duplicate-test가 됩니다:
vitest-dedup 0.4.0 reports a near-duplicate warning for the renamed local classes; 0.5.0 reports a duplicate-test error
로컬 클래스와 클로저가 어려운 이유
테스트 코드는 작은 로컬 클래스와 클로저(closure)들로 가득합니다: 가짜 시계(fake clocks), 스파이 버스(spy buses), 기록 저장소(recording repositories). 이들은
생성자(constructor)는 정의 시점과 생성 시점에 효과(effects)를 가질 수 있으며, 이들은 서로 다른 순간입니다. 이들을 합치면 두 테스트가 동일해 보이게 만드는데, 그중 하나가 관찰하는 부수 효과(side effect)를 다른 하나가 절대 트리거하지 않을 때 발생합니다.
속성 종류(Property kinds)도 중요합니다. 다음 두 객체는 거의 같은 것을 출력합니다:
const fromGetter = { get limit() { return 3 } } // fromGetter.limit === 3
const fromMethod = { limit() { return 3 } } // fromMethod.limit은 함수입니다
이 두 테스트는 어떤 속성을 사용했는지에만 차이가 나는데, 이는 0.4.0 버전에서 duplicate-test 오류를 유발했습니다. 0.5.0 버전에서는 속성 설명자 종류(property descriptor kinds)를 분리하여 보고하며 아무것도 보고하지 않습니다.
클로저(Closures)는 가변 상태(mutable state)를 추가합니다. 만약 테스트가 배열을 포착하고 헬퍼 함수가 그 배열에 푸시(push)한다면, 비교는 초기화 값뿐만 아니라 해당 배열의 이력(history)을 따라야 합니다. 만약 포착된 객체가 분석기(analyzer)가 볼 수 없는 코드 영역으로 빠져나간다면, 그것에 대한 어떤 주장도 추측일 뿐입니다.
이 마지막 지점이 제가 가장 중요하게 생각하는 설계 결정입니다. 분석기가 모델링할 수 없는 것들, 예를 들어 인스턴스가 존재하는 후에 패치된 프로토타입(prototype), 데코레이터(decorator), 또는 리플렉티브 접근(reflective access) 같은 경우, 의존 테스트는 표면 텍스트로 비교되는 대신 해결되지 않은 것으로 표시되어 비교에서 제외됩니다. 이런 일이 발생하면 실행이 불완전한 것으로 플래그가 지정되는데, 이는 누락된 발견 사항(finding)이 곧 누락된 중복의 증거가 아니기 때문입니다. 같은 파일 내의 관련 없는 테스트들은 여전히 정상적으로 비교됩니다.
실험
두 개의 작은 TypeScript 모듈: 쇼핑 카트(items, 쿠폰, 세금, 센트로 반올림)와 주입 가능한 클럭을 가진 토큰 버킷 속도 제한기(token bucket rate limiter). 각각은 40~60줄입니다.
각 모듈에 대해, 신선한 코딩 에이전트 세션(Claude)에게 의도적으로 평범한 프롬프트가 주어졌습니다: 이 모듈에 대한 포괄적인 Vitest 스위트를 작성하고 통과하는지 확인하세요. 그런 다음 두 번째 신선한 세션에는 모든 팀이 한때 작성했던 후속 질문이 주어졌습니다: 리뷰어가 테스트가 충분하지 않다고 말하며, 엣지 케이스(edge cases)를 추가하되 기존 테스트는 유지해 달라는 내용입니다. 저는 생성된 파일을 수정하지 않았습니다.
결과는 139개의 통과 테스트였습니다. 첫 번째 세션에서 93개, 두 번째 세션에서 46개가 있었습니다. 저는 위와 같은 손으로 작성한 트랩 테스트 10개를 별도의 파일에 추가했습니다.
그런 다음 저는 모든 감지기가 플래그를 지정한 쌍을 분류하고, 아무것도 플래그를 지정하지 않은 중복성을 찾기 위해 두 스위트를 처음부터 끝까지 읽었습니다. 각 쌍에 대한 질문은 간단했습니다. 만약 테스트 하나가 삭제된다면, 어떤 동작이 유일한 검증을 잃게 될까?
감지기가 발견한 것들
에이전트가 작성한 139개 테스트에서:
| Naive type-1 | Naive type-2 | vitest-dedup 0.4.0 | vitest-dedup 0.5.0 |
|---|
| 정확하거나 이름이 변경된 중복 보고 건수 | 0 | 문자 그대로의 변형과 구별 불가 | 0 | 0 |
| ... | | | | |
| 첫 번째 놀라움: 에이전트는 복사하여 붙여넣지 않았습니다. 139개 테스트에서 단 하나의 정확하거나 이름이 변경된 중복도 없었습니다. 반복은 모양의 반복이었으며, 다른 숫자를 가진 동일한 배열, 동작, 단언으로 이루어져 있었습니다. | | | | |
vitest-dedup 0.5.0은 31개의 테스트를 다루는 18개의 parameterization-candidate 발견 사항을 보고했는데, 모두
0.4.0 버전은 여전히 'accepts fractional capacity'와 'can take exactly the full capacity' 사이에 유사한 중복 테스트 18개를 보고했습니다. 이 두 가지는 다른 동작입니다. 그런데 0.5.0 버전에서는 더 이상 이 쌍을 보고하지 않습니다.
단순 유형-2 검출기(naive type-2 detector)도 대략 같은 클러스터를 찾았지만, 모든 식별자를 플레이스홀더로 대체하자 cart.total()과 cart.discount()가 동일하게 보이게 되었습니다. 그 결과 11개의 그룹 중 세 개의 그룹이 서로 다른 메서드를 호출하는 테스트들을 혼합했습니다. 또한 이 검출기는 '리터럴에서 차이가 나는 경우'와 '어떤 함수를 호출하는지에서 차이가 나는 경우'를 구별할 수 없는데, 이는 consolidate와 leave alone의 차이점입니다.
아무도 지적하지 않은 중복성
수동으로 세어보니, 테스트 스위트의 약 7%에 해당하는 10개의 테스트가 삭제되어도 검증을 잃지 않을 수 있었습니다. 이 숫자는 제가 예상했던 것보다 적었고, 더 흥미로운 것은 이러한 테스트들이 어디에서 왔는지입니다.
일부는 포괄(subsumption) 문제입니다. 즉, 나중에 작성된 테스트가 더 많은 체크포인트를 사용하여 동일한 단계를 수행합니다. 두 번째 세션에서는 시계가 역행하는 경우를 테스트하며 네 개의 타임스탬프를 거치는 테스트를 작성했는데, 이는 첫 번째 세션의 이전 역행 시계 테스트 두 개를 중복으로 만듭니다. 이들은 서로 다른 숫자와 순서를 사용하므로 구조적인 비교로는 연결할 수 없습니다.
일부는 퇴화 차원(degenerate dimensions) 문제입니다. '특성 간 상호작용'을 요청받자, 두 번째 세션에서는