AI가 작성한 테스트가 통과된다고 해서, 그것이 제대로 작동한다는 뜻은 아닙니다
요약
AI가 생성한 테스트 코드가 높은 커버리지를 기록하더라도 실제 로직을 제대로 검증하지 못할 수 있는 위험성을 경고합니다. 이를 해결하기 위해 코드 변경 사항에 대해서만 효율적으로 작동하는 뮤테이션 테스팅 도구인 aitg를 소개합니다.
핵심 포인트
- 높은 코드 커버리지가 반드시 코드의 올바른 동작을 보장하지는 않음
- AI 어시스턴트는 검증력이 낮은 약한 어설션을 대량으로 생성할 위험이 있음
- 뮤테이션 테스팅은 코드 변경 시 테스트가 실패하는지 확인하여 검증력을 측정함
- aitg는 git diff를 활용해 변경된 부분만 뮤테이션하여 비용 문제를 해결함
여기 함수가 하나 있습니다. 세 개의 분기(branch), 두 개의 임계값(threshold)이 있습니다.
export function classify(score) {
if (score >= 80) return "healthy";
if (score >= 50) return "warning";
...
그리고 AI 어시스턴트가 기꺼이 만들어낼 법한 테스트 스위트(test suite)가 여기 있습니다:
test("returns a value for a high score", () => {
expect(classify(95)).toBeDefined();
});
...
세 개의 테스트. 모든 라인이 실행되었습니다. 커버리지(Coverage): 100%.
이제 2번 라인의 >=를 >로 바꿔보세요. <로 바꿔보세요. 조건문 전체를 true로 교체해 보세요. 두 번째 분기를 완전히 삭제해 보세요.
테스트는 여전히 통과합니다. 전부 다요. 매번 말이죠.
왜냐하면 toBeDefined()는 함수가 무엇을 반환했는지 확인하는 것이 아니라, 단지 _무언가_를 반환했는지만 확인하기 때문입니다. 테스트는 코드를 실행할 뿐, 검증(verify)하지는 않습니다.
커버리지는 잘못된 것을 측정합니다
커버리지는 다음 질문에 답합니다: 테스트가 이 라인을 실행했는가?
당신이 실제로 신경 써야 할 질문은 이것입니다: 만약 이 라인이 잘못되었다면 테스트가 실패할 것인가?
이 둘은 같은 질문이 아니며, 그 사이의 간극에 버그가 존재합니다. 한 라인이 20개의 테스트에 의해 커버되더라도 여전히 완전히 보호받지 못할 수 있습니다.
이것은 항상 사실이었습니다. 변한 것은 그 양(volume)입니다. 사람이 약한 어설션(assertion)을 작성할 때는 보통 한두 번이며, 대개 피곤해서 그런 경우가 많습니다. 하지만 어시스턴트가 스위트를 생성할 때는 파일 단위로 약한 어설션이 쏟아져 들어오며, 심지어 그것들이 올바르게 보이기까지 합니다. toBeDefined(), expect(fn).toHaveBeenCalled(), expect(result).toBeTruthy(). 이것들은 테스트처럼 읽힙니다. 코드 리뷰(code review)도 통과합니다. 커버리지 보고서도 초록색으로 밝힙니다.
뮤테이션 테스팅(Mutation testing)은 올바른 질문을 던집니다
뮤테이션 테스팅(Mutation testing)은 의도적으로 소스 코드를 변경합니다. >=를 >로 바꾸거나, 조건을 true로 바꾸거나, 불리언(boolean) 값을 부정하는 식입니다. 그리고 각 변경 사항에 대해 테스트 스위트를 다시 실행합니다.
만약 테스트가 실패한다면, 좋습니다. 무언가가 실제로 그 동작을 확인하고 있었다는 뜻입니다. 뮤턴트(mutant)가 **살해(killed)**된 것입니다.
만약 모든 테스트가 여전히 통과한다면, 그 동작은 실행되지만 검증되지 않은 것입니다. 뮤턴트가 **생존(survived)**한 것이며, 당신은 실제적인 간극을 찾아낸 것입니다.
이것은 수십 년 된 기술입니다. 이것이 표준 관행이 아닌 이유는 비용 때문입니다. 전체 저장소(repository)를 뮤테이션(mutating)한다는 것은 테스트 스위트(test suite)를 수백 또는 수천 번 실행해야 함을 의미합니다.
변경 사항(diff)으로 범위 제한하기
저장소 전체를 뮤테이션할 필요는 없습니다. 당신에게 필요한 것은 이 풀 리퀘스트(pull request) 내의 코드가 실제로 테스트되고 있는지 여부를 아는 것입니다.
그것이 aitg의 핵심 아이디어입니다. git diff에서 변경된 라인을 가져와, 오직 그 부분만 뮤테이션하고 이에 대해 보고하는 것입니다. 몇 분이 걸리던 작업이 몇 초로 단축됩니다. 이 도구는 실제 뮤테이션 작업을 수행하는 StrykerJS 위에서 실행됩니다.
npm install --save-dev @aitg/cli @stryker-mutator/core
npx aitg init --local # 계정 불필요, 아무것도 업로드되지 않음
npx aitg scan
위의 분류기(classifier)에서:
Mutation testing complete (vitest, 67 mutants).
Results
...
커버리지(coverage) 100%. 뮤테이션 점수(mutation score) 0%. 어떤 테스트도 잡아내지 못할 코드를 망가뜨리는 여덟 가지 방법이 발견되었습니다.
출력 결과는 실행 가능(actionable)해야 합니다
생존한 뮤턴트(surviving mutants) 목록 그 자체만으로는 유용하지 않습니다. 따라서 이 도구는 각 간극(gap)에 대한 설명을 포함하여, 당신이 사용하는 어떤 어시스턴트(assistant)에도 붙여넣을 수 있는 프롬프트를 작성해 줍니다.
- if (score >= 80) return "healthy";
+ score > 80
비교 경계(comparison boundary)가 이동되었으나(포함/미포함) 어떤 테스트도 이를 알아차리지 못했습니다. 따라서 정확한 경계값(edge value)이 테스트되지 않은 상태입니다. 양쪽의 값뿐만 아니라 경계값 자체를 단언(assert)하세요.
반면, 동일한 라인에서 다음과 같은 경우:
- if (score >= 80) return "healthy";
+ score < 80
비교(comparison)가 반전되었으나 어떤 테스트도 이를 알아차리지 못했습니다. 따라서 체크의 방향이 검증되지 않았습니다.
이들은 서로 다른 테스트가 필요합니다. >=가 >로 바뀐 것은 정확히 80인 지점에서만 구별할 수 있습니다. 반면 >=가 <로 바뀐 것은 거의 모든 곳에서 구별 가능합니다. 첫 번째 사례에 대해 개발자에게 "등호 체크가 반전되었습니다"라고 말하는 것은 잘못된 테스트를 작성하도록 유도하는 것입니다.
설명은 단순히 카테고리에서 가져오는 것이 아니라 실제 뮤테이션으로부터 도출됩니다. 이는 생각보다 훨씬 중요합니다. 위에 출력된 diff와 명백히 모순되는 설명이 나온다면, 보고서의 다른 모든 줄에 대한 신뢰를 잃게 되기 때문입니다.
커버되지 않은 코드 (Uncovered code)는 별개의 문제입니다
만약 어떤 코드 라인에도 도달하는 것이 없다면, 해당 라인의 어떤 변이(mutant)도 살아남을 수 없습니다. 즉, 실행 자체가 되지 않기 때문입니다. 따라서 테스트가 전혀 없는 파일이 부실한 테스트가 있는 파일보다 더 깨끗해 보일 수 있습니다.
이는 본말이 전도된 상황이므로, 커버되지 않은 코드(uncovered code)는 별도의 섹션으로 다루며 그 차이점을 명확히 설명합니다. 생존자(survivor)가 발생했다는 것은 이미 존재하는 단언(assertion)을 강화해야 함을 의미하며, 커버리지가 없다는 것은 해당 코드에 도달하는 테스트를 새로 작성해야 함을 의미합니다.
이 도구가 수행하지 않는 것
이 도구는 당신의 아키텍처가 잘못되었다거나, 완전히 엉뚱한 것을 테스트하고 있다는 사실을 알려주지는 않습니다. 이 도구는 한 가지 특정한 속성, 즉 코드가 변경되었을 때 당신의 단언(assertion)이 이를 감지할 수 있는지 여부를 측정합니다.
또한 시작을 위해서는 통과하는 테스트 스위트(test suite)가 필요합니다. 변이 테스트(Mutation testing)는 변이된 실행 결과와 통과된 기준선(green baseline)을 비교합니다. 기준선이 없다면 신호(signal)도 없습니다.
그리고 커버리지(coverage) 측정보다 느립니다. diff 범위로 한정하면 몇 초 정도 걸리지만, 공짜는 아닙니다.
도구 자체를 테스트하는 것에 대하여
테스트 품질을 측정하는 도구라면 그 자체로 괜찮은 테스트를 갖추고 있어야 합니다. 이 도구는 54개의 테스트를 가지고 있습니다.
더 중요한 점은, 이 테스트들이 커버리지를 세는 방식이 아니라 변이(mutation)를 통해 검증되었다는 것입니다. 개발 과정에서 수정된 6개의 결함을 의도적으로 다시 도입했으며, 각 결함은 반드시 테스트를 실패하게 만들어야 했습니다.
그중 하나가 실패하지 않았습니다. 해당 테스트는 diff 엔진이 브랜치의 끝(branch tip)이 아닌 머지 베이스(merge-base)와 비교하는지 확인하는 것이었는데, 두 경우 모두 통과했습니다. 피스처(fixture)의 갈라진 커밋(divergent commit)이 파일을 _추가_했는데, 이는 끝(tip)과 diff를 수행할 때 삭제로 나타나며, 삭제는 어차피 건너뛰어지기 때문입니다. 두 전략 모두 동일한 가시적 결과를 생성했습니다.
테스트는 올바르게 보였고, 통과했습니다. 하지만 아무것도 검증하지 못했습니다.
이제 이 도구는 대신 공유 파일을 수정하여 변이를 잡아냅니다. 하지만 이것은 바로 이 포스트의 상단에서 설명한 실패 모드이며, 이를 탐지하기 위해 만들어진 도구의 스위트에서 발견된 사례입니다. 만약 여기서 숨을 수 있다면, 어디에서든 숨을 수 있습니다.
시도해보기
npx aitg init --local
npx aitg scan
가장 최근의 PR (Pull Request)에서 실행해 보세요. 만약 변이 점수 (mutation score)가 커버리지 (coverage) 수치와 비슷하게 나온다면, 당신의 테스트가 제 역할을 하고 있는 것입니다. 만약 차이가 있다면, 그 차이가 바로 버그가 빠져나가는 지점입니다.
- npm:
@aitg/cli - GitHub: tzurid25/aitg
MIT 라이선스입니다. 이 CLI는 완전히 오프라인으로 작동하며, 계정이 필요하지 않고 아무것도 업로드되지 않습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기