AI가 작성한 테스트는 너무 순하다: Mutation Testing과 Property-Based Testing으로 결함을 찾아내기
요약
AI가 작성한 테스트는 코드를 재확인하는 수준에 그쳐 실제 버그를 잡아내지 못할 수 있습니다. 본 글은 높은 커버리지의 함정을 지적하며, 'Mutation Testing'과 'Property-Based Testing'이라는 두 가지 기법을 소개합니다. 이 방법들은 테스트가 코드의 결함을 능동적으로 찾아내도록 돕습니다.
핵심 포인트
- 높은 커버리지는 충분한 테스트를 보장하지 않습니다.
- Mutation Testing은 코드를 고의로 파괴하여 테스트의 취약점을 찾습니다.
- Python에서는 mutmut, JavaScript/TypeScript에서는 StrykerJS 등을 사용할 수 있습니다.
요즘 PR을 리뷰하다 보면 매우 익숙한 패턴이 눈에 띕니다. AI가 코드를 작성했고, 테스트도 AI가 작성했으며, 커버리지는 95%이고 CI는 초록색입니다. 보기에는 안심되지만, 배포 후에 '당연히' 발생하는 버그를 또 만납니다. 이유는 상당히 간단합니다. 코드와 테스트를 같은 모델로 작성할 경우, 테스트는 코드가 하는 일을 재확인하는 데 그칠 뿐, 실제로 코드가 올바른지 검증하지 못하기 때문입니다. 이런 테스트는 너무 '순해서', 코드에 반박하지 않습니다. 최근 Dev.to에는 "AI Needs a Test That Fights Back"이라는 글이 있었고, HN에서는 AI를 잘 쓰는 것도 하나의 기술이라고 논의되고 있습니다. 저는 이 두 가지 의견 모두에 동의합니다. 본문에서는 제가 실제로 사용하고 있는 두 가지 기법, 즉 mutation testing과 property-based testing을 공유하여 테스트가 '반격'하도록 만드는 방법을 알려드리겠습니다.
문제: 높은 커버리지가 좋은 테스트를 의미하지 않는다
커버리지는 어느 코드 라인이 _실행되었는지_만 알려줄 뿐, 테스트가 필요한 것을 _정확히 단언(assert)하는지_는 알 수 없습니다. 실제 예를 들어보겠습니다:
# src/pricing.py
def apply_discount(price: int, percent: int) -> int:
if percent < 0 or percent > 100:
...
이것은 AI가 위 함수에 생성한 테스트이며, 흔히 볼 수 있는 전형적인 형태입니다:
# tests/test_pricing.py
import pytest
from src.pricing import apply_discount
...
커버리지는 100%를 달성했습니다. 만약 누군가 실수로 percent > 100을 percent >= 100으로 변경한다면 어떻게 될까요? 혹은 //을 /로 바꾼다면요? /의 경우, apply_discount(100, 10)은 90.0을 반환하며, 파이썬에서 90.0 == 90은 여전히 True입니다. 이 두 가지 버그 모두 테스트에 의해 통과되어 버립니다. 이런 식의 테스트는 안전하다는 느낌만 줄 뿐, 아무런 보호도 해주지 못합니다.
Mutation testing: 코드를 고의로 파괴하여 테스트가 감지하는지 확인하기
mutation testing의 아이디어는 다음과 같습니다. 도구가 코드의 수많은 '돌연변이'(mutant) 버전을 자체적으로 생성합니다. 예를 들어, >를 >=로, +를 -으로, True를 False로 바꾸어 각 mutant와 테스트 스위트를 다시 실행하는 것입니다. Mutant가 테스트를 실패하게 만들면 (killed) 이는 좋은 신호입니다. 반면, 테스트가 여전히 통과한다면 (survived), 이는 당신의 테스트에 정확히 그 부분에 결함이 있다는 의미입니다.
flowchart LR
A[Source code] --> B[Mutation tool]
B --> C[Mutant 1: > thành >=]
...
Python의 경우, mutmut 3.x를 사용합니다. 설정 방법은 다음과 같습니다:
pip install "mutmut>=3.0" pytest hypothesis
# pyproject.toml
...
위 테스트 스위트(test suite)로 실행하면, mutmut이 몇 개의 살아남은 mutant를 보고하는데, 그중 우리가 언급한 두 개의 >=와 / mutant가 맞습니다. 이것은 일반적인 커버리지 수치가 아니라 구체적으로 해야 할 작업 목록입니다.
JavaScript/TypeScript의 경우 StrykerJS (8.x 이상 버전)를 사용합니다:
npm i -D @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker init
stryker.config.json에서는 mutation score가 떨어질 때 CI(Continuous Integration)가 실패하도록 임계값(threshold)을 항상 설정합니다:
{
"testRunner": "vitest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
...
incremental: true를 활성화하는 것이 좋습니다. 왜냐하면 mutation testing은 느리게 실행되기 때문입니다 (N개의 mutant는 N번의 테스트 실행과 동일합니다). 리포지토리가 클 경우, PR(Pull Request)에서 변경된 파일에 대해서만 실행합니다.
Property-based testing: AI가 입력을 선택하도록 두지 마세요
AI가 작성한 테스트의 두 번째 문제는 이것이
- AI에게 뮤턴트의 diff를 제공하세요. 단순히 '테스트를 더 써줘'라고 요청하는 대신, '이 뮤턴트는 살아남았으니, 이 뮤턴트를 실패시키지만 원본 코드에는 통과하는 테스트를 작성해 줘'와 같은 프롬프트를 사용하면 훨씬 좋은 결과를 얻을 수 있습니다. AI가 명확한 목표('공격할 대상')를 가지기 때문입니다.
- 속성은 직접 정의하고, 구현은 AI에게 맡기세요. '결과가 절대 음수가 되지 않는다'거나 '인코딩했다가 디코드해도 원래 입력값으로 돌아온다'와 같은 속성(Property)은 비즈니스 로직이므로, 사용자가 이해하고 직접 작성해야 합니다.
- 100% 뮤테이션 스코어에 집착할 필요는 없습니다. 코드의 동작 방식만 바뀐 유사한 뮤턴트(equivalent mutant)가 존재하기 때문입니다. 핵심 로직에는 약 70~80%를 목표로 하고, 접착제 코드(glue code) 부분은 제외해도 됩니다.
- 뮤테이션 테스트는 매일 밤 또는 변경된 파일에 대해서만 실행하세요. 레포지토리가 크다면 모든 커밋마다 실행할 필요는 없습니다.
결론
AI가 테스트 작성을 훨씬 빠르게 도와주지만, 버그를 잡아내지 못하는 빠른 테스트는 높은 커버리지를 가진 쓰레기(slop)에 불과합니다. 이번 주에 바로 시도해 볼 수 있는 것들:
mutmut(Python) 또는@stryker-mutator/core(JS/TS)를 설치하고, 커버리지가 높은 핵심 모듈 하나에 적용하여 실행해 보세요. 뮤턴트가 살아남는 것을 거의 확실히 발견할 수 있을 것입니다.- 금전 처리, 날짜/시간 처리 또는 파싱을 담당하는 함수마다 Hypothesis나 fast-check를 사용하여 최소 **하나의 속성 테스트(property test)**를 작성하고, 경계값(0, 최대값, 음수)에 집중하세요.
- AI에게 프롬프트를 변경하세요: '이 함수에 대한 테스트를 써줘' 대신, 살아남은 뮤턴트의 diff를 제공하고 그것을 죽이는 테스트를 요청하세요.
- CI에
break임계값을 추가하여 뮤테이션 스코어가 시간이 지나도 은밀하게 떨어지는 것을 방지하세요.
좋은 테스트는 코드가 틀렸을 때 실패할 수 있는 능력이 있어야 합니다. 만약 자신의 테스트가 한 번도 실패한 적이 없다면, 의심해 봐야 합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기