커버리지 연극: 아무것도 테스트하지 않는 AI 테스트
요약
AI가 생성한 테스트 코드가 실제 동작을 검증하지 않고 단순히 코드 라인 실행률(커버리지)만 높이는 '커버리지 연극(Coverage Theater)' 현상을 경고합니다. AI는 명세가 아닌 구현을 바탕으로 테스트를 추론하기 때문에 의미 없는 어설션만 생성할 위험이 있습니다.
핵심 포인트
- AI는 동작 검증 대신 라인 실행률을 높이는 테스트를 생성할 수 있음
- 커버리지 지표가 높더라도 실제 버그를 잡아내지 못하는 현상 발생
- AI가 명세가 아닌 구현으로부터 테스트를 추론하는 문제점
- 순환 어설션, 과도한 모킹 등 커버리지 연극의 주요 패턴 주의
당신의 CI 파이프라인은 83%의 커버리지 (coverage)를 보고합니다. AI 어시스턴트는 2분도 채 되지 않아 새로운 모듈의 모든 함수에 대해 각각 하나씩, 총 27개의 새로운 테스트 함수를 생성했습니다. PR은 깔끔해 보입니다. 커버리지 게이트 (coverage gate)도 통과합니다.
하지만 버그는 그대로 배포됩니다.
커버리지 연극 (Coverage theater)이란, AI가 생성한 테스트가 의미 있는 동작에 대한 어설션 (assertion) 없이 라인 실행 퍼센티지 (line-execution percentage)만 높여 놓을 때 발생하는 현상입니다. 지표는 올바르게 보입니다. 테스트는 실행되었습니다. 하지만 결과적으로 테스트 스위트 (test suite)는 거의 아무것도 잡아내지 못했습니다.
AI가 동작을 테스트하지 않고 커버리지만 통과하는 테스트를 작성하는 방법
AI 코딩 어시스턴트들은 측정 가능한 지표를 목표로 합니다. 테스트할 함수가 주어지면, 이들은 모든 동작이 아닌 모든 라인을 실행하는 테스트 케이스를 생성합니다. BrassCoders는 테스트 품질을 평가하지 않습니다. 대신 테스트 스위트가 무엇을 커버하든 상관없이 보안 및 정확성 버그를 찾기 위해 프로덕션 코드 (production code)를 스캔합니다. SQL 인젝션 (SQL injection) 버그는 80%의 커버리지 유무와 상관없이 배포될 수 있기 때문입니다.
hash_password 함수를 전달받은 AI는 그 구조, 즉 분기 (branches), 파라미터 타입 (parameter types), 반환 값 (return values)을 읽습니다. 그리고 assert hash_password("") is not None과 assert hash_password("secret") is not None을 생성합니다. 두 라인 모두 실행됩니다. Coverage.py는 이를 카운트합니다. 하지만 두 어설션 중 어느 것도 해당 함수가 안전한 알고리즘을 사용하는지 또는 솔트 (salt)를 적용하는지 알려주지 않습니다. 구현 (implementation)이 bcrypt에서 MD5로 바뀌더라도 두 테스트 중 어느 것도 실패하지 않을 것입니다.
이런 일이 발생하는 이유는 AI 어시스턴트가 명세 (specification)가 아닌 구현 (implementation)으로부터 테스트를 추론하기 때문입니다. 코드가 주어지면, 이들은 코드가 현재 수행하고 있는 것과 일치하는 테스트를 생성합니다. 그것은 테스트가 아닙니다. 그것은 버그의 스냅샷 (snapshot)일 뿐입니다.
커버리지 연극의 네 가지 패턴
BrassCoders는 테스트 품질을 스캔하지는 않습니다. 이는 정적 분석 (static analysis)의 범위를 벗어나는 일이기 때문입니다. 하지만 커버리지 연극이 만들어내는 네 가지 패턴은 명명할 가치가 있습니다: 순환 어설션 (circular assertions, 테스트를 작성할 당시 함수가 반환했던 값을 다시 반환하는지 확인하는 것), 테스트 대상 자체를 모킹 (mocking)하는 것, 결과 대신 구현 세부 사항을 테스트하는 것, 그리고 동작당 하나의 테스트가 아닌 함수당 하나의 테스트를 생성하는 것입니다.
순환 단언 (Circular assertions)은 가장 심각한 위반 사례입니다. calculate_tax(income, rate)를 테스트하는 AI는 다음과 같은 코드를 생성할 수 있습니다: expected = calculate_tax(1000, 0.2); assert calculate_tax(1000, 0.2) == expected. 이 테스트는 함수가 예외 (exception)를 발생시키지 않는 한 무조건 통과합니다. 호출당 하나의 커버된 라인을 기록할 뿐, 아무것도 검증하지 않습니다.
테스트 대상 (thing under test)을 모킹 (Mocking)하는 방식은 다음과 같습니다: 데이터베이스 쿼리 함수가 있을 때, 데이터베이스 연결을 모킹하고 해당 모킹이 올바른 인자 (arguments)와 함께 호출되었는지 단언 (assert)합니다. 당신은 Python의 unittest.mock 라이브러리를 테스트한 것입니다. 실제 쿼리 로직 — 매개변수화된 문 (parameterized statements)을 생성하는지 또는 null 값을 처리하는지 여부 — 은 전혀 검증되지 않은 채 남습니다.
구현 세부 사항 (implementation details)을 테스트한다는 것은 호출자와의 계약 (contract)이 아니라 함수의 내부 호출 그래프 (call graph)에 대해 단언을 작성하는 것을 의미합니다. 내부 로직을 리팩터링 (refactor)하면 동작은 변하지 않았음에도 테스트가 깨집니다. 회귀 버그 (regression)는 그대로 배포됩니다.
네 번째 패턴인 '함수당 하나의 테스트'는 커버리지 게이트 (coverage gate)를 통과합니다. 커버리지 도구는 동작 경로 (behavior paths)가 아니라 라인 실행 (line execution)을 추적하기 때문입니다. 다섯 개의 에러 조건, 두 개의 엣지 케이스 (edge cases), 그리고 하나의 해피 패스 (happy path)를 가진 함수는 단 하나의 테스트, 즉 해피 패스에 대해서만 테스트를 받습니다. 나머지 네 가지 조건은 테스트되지 않은 채로 남습니다. 하지만 함수는 커버된 것으로 표시됩니다.
지표는 괜찮아 보이지만 버그는 왜 배포되는가
Coverage.py는 테스트 스위트 동안 어떤 라인이 실행되었는지를 측정합니다. 해당 테스트의 단언 (assertions)이 버그를 잡아낼 수 있는지 판단하는 메커니즘은 없습니다. BrassCoders는 커버리지 지표가 놓치는 운영 코드 (production-code)의 버그를 잡아냅니다: 예를 들어, 100% 라인 커버리지를 가졌지만 매개변수화된 쿼리를 확인하는 단언이 없는 데이터베이스 핸들러의 SQL 인젝션 (SQL injection) 같은 경우입니다.
Martin Fowler의 TestCoverage 기사는 이 점을 명확히 짚어줍니다: 커버리지는 부정적인 지표 (negative indicator)로서 유용합니다. 낮은 커버리지는 분명히 문제입니다. 높은 커버리지는 무언가가 실행되었다는 것을 알려주지만, 그 실행이 회귀 (regression)를 잡아낼 수 있는지에 대해서는 아무것도 알려주지 않습니다.
이러한 불일치는 팀들이 커버리지 (coverage)가 무엇을 의미한다고 가정하는 데서 발생합니다. 즉, '어떤 라인이 커버되었다면, 누군가가 그 라인을 실행하는 어설션 (assertion)을 작성했을 것'이라는 암묵적인 믿음입니다. 사람이 작성한 테스트의 경우 이는 종종 사실입니다. 하지만 커버리지 게이트 (coverage gate)를 목표로 하는 AI 생성 테스트의 경우, 이는 빈번하게 거짓이 됩니다. AI는 회귀 (regression)를 잡아낼 수 있는 어설션이 아니라, 단순히 해당 라인을 실행시키기만 하면 되는 어떤 어설션이든 작성하기 때문입니다.
변이 테스트 (Mutation testing)는 이러한 격차를 가시화합니다. Mutmut과 Cosmic Ray는 프로덕션 코드에 의도적인 버그를 주입하고, 변이된 코드에 대해 테스트 스위트 (test suite)를 실행합니다. 만약 버그가 존재하는 상태에서도 테스트가 통과한다면, 해당 테스트들은 그 동작을 테스트하고 있지 않은 것입니다. Coverage.py 문서에서도 커버리지 데이터는 단독 게이트가 아니라 다른 품질 신호 (quality signals)와 함께 사용할 때 가장 효과적이라고 명시하고 있습니다. 변이 테스트가 바로 그 '다른 신호'입니다.
커버리지 도구가 잡지 못하는 것을 BrassCoders가 잡는 방법
BrassCoders의 12가지 스캐너는 테스트 코드가 아닌 프로덕션 코드를 대상으로 실행됩니다. 즉, 테스트가 무엇을 어설션하든 관계없이 구현부 (implementation)에 있는 보안 및 정확성 버그를 잡아냅니다. 데이터베이스 쿼리의 SQL 인젝션 (SQL injection) 버그는 테스트 커버리지가 80%이든 아니든 상관없이 배포될 수 있지만, BrassCoders는 이를 결정론적으로(deterministically) 찾아냅니다.
스캐너들은 구현 코드 위에서 작동합니다. Bandit, detect-secrets, Pyre/Pysa, Semgrep, ast-grep, 그리고 6개의 커스텀 디텍터 (custom detectors) — 이것들이 BrassCoders가 오케스트레이션 (orchestrates)하는 업스트림 (upstream) 도구들입니다. 커스텀 디텍터들은 비밀번호 형식 패턴, 개인정보 및 PII (Personally Identifiable Information) 처리, AI 생성 코드의 팬텀 API 호출 (phantom API calls), 성능 안티 패턴 (performance anti-patterns), 콘텐츠 모더레이션 (content-moderation) 신호, 그리고 JavaScript/TypeScript를 다룹니다. 데이터베이스 핸들러에 100% 테스트 커버리지를 부여하더라도, BrassCoders는 첫 번째 스캔에서 여전히 SQL 인젝션을 찾아냅니다.
/blog/ai-coder-bug-benchmark/에 게시된 벤치마크에서는 AI 코딩 어시스턴트인 Copilot, Cursor, Claude Code에서 추출한 12개의 실제 버그를 대상으로 Bandit 단독 사용 시와 BrassCoders 사용 시를 비교 테스트했습니다. Bandit은 12개 중 6개를 찾아냈습니다. BrassCoders는 12개 중 11개를 찾아냈습니다. Coverage.py를 사용했다면 이 버그들 대부분이 커버된 것으로 나타났을 것입니다. 해당 버그들을 포함하는 함수들은 모두 테스트가 존재했기 때문입니다.
Coverage.py와 BrassCoders는 서로 다른 질문에 답합니다. Coverage.py는 "테스트 스위트(test suite)가 어떤 라인을 실행했는가?"라는 질문에 답합니다. 반면 BrassCoders는 "프로덕션 코드(production code)에 어떤 보안 및 정확성 패턴이 포함되어 있는가?"라는 질문에 답합니다. 코드베이스는 첫 번째 질문에서 95%의 점수를 기록하면서도, 여전히 악용 가능한 인젝션 결함(injection flaws)이나 하드코딩된 자격 증명(hardcoded credentials)을 포함하고 있을 수 있습니다. 두 답변은 서로 중첩되지 않으며, 이것이 바로 두 가지 모두가 필요한 이유입니다.
pip install brasscoders를 실행한 후 brasscoders scan .을 입력하여 어떤 스캐너가 귀하의 프로덕션 코드에서 트리거되는지 확인해 보세요. 오픈 소스(OSS) 코어는 무료이며, Apache 2.0 라이선스를 따르고, 어떠한 데이터도 귀하의 기기 외부로 전송하지 않습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기