AI 생성 테스트 부채: 통과하지만 아무것도 테스트하지 않는 테스트
요약
AI 코딩 어시스턴트가 생성하는 테스트 코드가 실제 로직을 검증하지 못하고 통과만 하는 '테스트 부채' 문제를 다룹니다. 항상 참인 어설션이나 빈 테스트 본문과 같이 실질적인 검증 능력이 결여된 패턴의 위험성을 경고합니다.
핵심 포인트
- AI가 생성한 테스트는 문법적으로는 유효하나 검증력이 낮은 '약한 어설션'을 자주 사용함
- 항상 참인 어설션(Always-True Assertion)은 버그를 발견하지 못하고 통과함
- 모의 객체(Mock)만 설정하고 단언(Assertion)이 없는 빈 테스트의 위험성
- 정적 분석기만으로는 잡아내기 어려운 논리적 검증 결여 문제
Green CI(성공적인 CI)가 테스트된 코드를 의미하는 것은 아닙니다. 그것은 단지 당신의 테스트가 실행되었고 충돌(crash)하지 않았음을 의미할 뿐입니다. 그것들이 실제로 무언가를 검증했는지는 별개의 문제이며, 이는 AI 코딩 어시스턴트(AI coding assistants)들이 조용히 답변하기 더 어렵게 만든 문제입니다.
AI 어시스턴트들은 테스트를 빠르게 작성합니다. 어설션(assertion)이 실제로 무엇을 확인하고 있는지 생각하기에는 너무 빠릅니다. 그 결과, 테스트 스위트(suite)를 실패하게 만들지 않는 버그 카테고리가 발생합니다. 즉, 존재하고, 통과하며, 아무것도 증명하지 못하는 테스트들입니다. 이 포스트에서는 가장 흔한 세 가지 패턴의 이름을 명시하고, 코드를 보여주며, BrassCoders가 잡아내는 것과 다른 도구가 필요한 것이 무엇인지에 대해 솔직하게 다룹니다.
항상 참인 어설션 (The Always-True Assertion)
BrassCoders는 Pylint를 통해 항상 통과하는 어설션 패턴을 드러내며, 표준 스캔 중에 테스트 코드에서 발견되는 동의어 반복 비교(tautological comparisons)와 assert True 문을 플래그(flagging)합니다. 이것들은 명백한 구조적 실패입니다. 더 어려운 사례는 — 문법적으로는 유효하지만 잘못된 것을 확인하기 때문에 항상 통과하는 어설션 — 이며, 이는 어떤 정적 분석기(static analyzer)도 신뢰성 있게 잡아낼 수 있는 범위를 벗어납니다.
다음은 AI 어시스턴트들이 가장 자주 생성하는 패턴입니다:
def test_get_user_profile():
response = client.get("/api/users/42")
assert response is not None
저 어설션은 함수가 무엇이든 반환하기만 하면 통과합니다. 함수가 404를 반환해도 통과합니다. {"error": "user not found"}를 반환해도 통과합니다. 이것은 호출이 예외(exception)를 발생시키지 않았는지를 확인하는 것인데, 이는 "이 엔드포인트가 올바르게 작동한다"라는 주장보다 훨씬 약한 주장입니다.
실제로 동작을 방어하는 테스트는 다르게 보입니다:
def test_get_user_profile():
response = client.get("/api/users/42")
assert response.status_code == 200
...
이제 엔드포인트를 삭제하거나 망가뜨리면 테스트가 실패합니다. 첫 번째 버전은 그렇지 않습니다. AI 어시스턴트들은 assert response is not None이 문법적으로 그럴듯하고 테스트의 형태와 일치하기 때문에 더 약한 형태를 기본값으로 사용합니다. 테스트를 생성한 도구는 "검증(verified)"이 무엇을 의미하는지 이해하지 못했습니다.
빈 테스트 본문 (The Empty Test Body)
BrassCoders의 Pylint 통합 기능은 일부 테스트 스캐폴딩(scaffolding)에서 W0613(사용되지 않는 인자)을 표시하며, 이는 피스처(fixtures)는 설정되었지만 전혀 사용되지 않는 함수들을 드러낼 수 있습니다. 모의 객체(mocks)를 설정하기만 하고 아무것도 단언(assert)하지 않는 테스트 함수의 구체적인 사례의 경우, 이를 안정적으로 잡아낼 수 있는 Pylint 규칙은 없습니다. pytest는 단언(assertion)을 요구하지 않으며, 함수 시그니처(function signature)만으로는 힌트를 얻을 수 없기 때문입니다.
이러한 패턴은 다음과 같습니다:
def test_send_notification(mock_email_client, mock_user_repo):
mock_user_repo.get.return_value = User(id=1, email="test@example.com")
mock_email_client.send.return_value = {"status": "queued"}
...
Pytest는 해당 함수를 실행하고, 모의 객체(mocks)는 설정된 값을 반환하며, 함수는 오류 없이 종료되어 테스트는 통과(pass)합니다. CI 보고서에는 통과한 것으로 기록됩니다. 하지만 이 테스트가 실제로 증명한 것은 아무것도 없습니다. send_notification 함수가 예외를 발생시키거나, 이메일을 조용히 무시하거나, 잘못된 상태를 반환하더라도 테스트는 그 중 어떤 것도 확인하지 않습니다.
이런 일이 발생하는 이유는 AI 어시스턴트가 모의 객체(mock) 설정까지는 테스트 스캐폴딩(scaffolding)을 올바르게 수행하다가, 그 이후 단계에서 멈추는 경우가 있기 때문입니다. 단언(assertion) 단계는 테스트 대상 코드에 대해 "올바른 동작"이 무엇을 의미하는지에 대한 이해를 필요로 합니다. 어시스턴트가 확신하지 못할 때, 잘못된 단언을 작성하기보다는 단언 자체를 생략해 버리는 것입니다.
pytest 플러그인인 pytest-warn-missing-assertions를 사용하면 단언(assertion) 없이 종료되는 테스트 함수를 표시할 수 있습니다. 모든 사례를 잡아낼 수는 없지만, assert, assertEqual 또는 어떠한 매처(matcher)도 호출하지 않는 함수 본문과 같이 가장 명확한 사례는 잡아낼 수 있습니다. 만약 코드베이스에서 이러한 패턴이 나타난다면 pyproject.toml에 추가할 가치가 있습니다.
모든 것을 모의 객체화하는 테스트 (Mock-Everything Tests)
BrassCoders는 모의 객체(mock) 과다 사용에 대한 정적인 규칙을 가지고 있지 않습니다. "적절한 격리 (appropriate isolation)"와 "코드가 아닌 모의 객체를 테스트하는 것"을 구분하는 임계값은 존재하지 않으며, 이를 구축하려면 스캐너 자체가 아닌 스캔 결과물을 사용하는 AI가 수행해야 할 종류의 의미론적 문맥 추론 (semantic context inference)이 필요합니다.
어쨌든 이 패턴은 인지할 가치가 있습니다:
@patch("myapp.services.notifications.UserRepository")
@patch("myapp.services.notifications.EmailClient")
@patch("myapp.services.notifications.send_notification")
...
테스트 대상인 함수인 send_notification 자체가 모킹 (mocking)되어 있습니다. 테스트는 모킹된 객체를 호출하고, 그 반환 값을 확인하며, 성공을 보고합니다. send_notification을 완전히 삭제하더라도 이 테스트는 여전히 통과합니다. 이것이 바로 진단 결과입니다: 만약 테스트 대상 함수를 삭제했는데도 테스트가 깨지지 않는다면, 그 테스트는 함수를 테스트하고 있지 않은 것입니다.
이 패턴은 AI 어시스턴트가 학습된 습관을 과도하게 적용하면서 발생합니다. 외부 의존성 (external dependencies)을 모킹하는 것은 올바른 방법입니다. 하지만 테스트 대상인 단위 (unit under test)를 모킹하는 것은 범주 오류 (category error)입니다. 어시스턴트는 "외부 의존성을 모킹하라"는 지침을 보고 이를 무차별적으로 적용합니다.
해결책은 각 모킹이 어떤 계층 (layer)을 대상으로 하는지 확인하는 것입니다. 만약 어떤 모킹이라도 테스트가 실행해야 하는 함수를 대체하고 있다면, 해당 모킹을 제거하고 실제 함수가 실행되도록 하세요. 해당 함수가 호출하는 외부 의존성들은 모킹된 상태로 두어도 괜찮습니다.
BrassCoders가 잡아내는 것 (그리고 잡아내지 못하는 것)
BrassCoders는 기본적으로 보안 및 정확성 스캐너 (security and correctness scanner)입니다. Bandit, Pyre/Pysa 오염 분석 (taint analysis), detect-secrets, Semgrep, 그리고 커스텀 AI 패턴 탐지기는 모두 프로덕션 코드 경로 (production code paths)에 집중합니다. 테스트 품질 문제는 부차적인 관심사이며, 탐지 범위도 부분적입니다.
BrassCoders가 테스트 코드에서 드러내는 사항은 다음과 같습니다:
항상 통과하는 단언 (assertion) 패턴. Pylint의 단언 체크는 assert True, assert 1 == 1, 그리고 동어반복적 비교 (tautological comparisons)를 플래그(flag)로 표시합니다. 또한 Python에서 괄호 없이 함수 호출처럼 사용되어 아무런 동작도 수행하지 않는 assert 문도 잡아냅니다.
테스트 픽스처 (test fixtures) 내의 자격 증명 (credentials). 테스트 파일에 하드코딩된 API 키, AWS 액세스 키, 또는 GitHub PAT가 포함되어 있다면 — 설령 그것이 가짜(fake)로 표시된 픽스처 내에 있더라도 — detect-secrets가 이를 찾아냅니다. BrassCoders는 이러한 정보가 사용자의 기기를 떠나기 전에 YAML 출력물에서 이를 삭제(redact)합니다. 테스트 내의 실제 자격 증명은 CI/CD 공급망 노출 (supply chain exposure)의 흔한 지점입니다.
테스트 유틸리티 내의 유령 API 호출 (Phantom API calls). AI 패턴 스캐너는 가져온(imported) 모듈에 존재하지 않는 함수를 호출하는 경우를 찾아냅니다. 이는 AI 어시스턴트가 때때로 만들어내는 환각(hallucinated)된 테스트 헬퍼 메서드를 잡아냅니다.
BrassCoders가 잡아내지 못하는 사항은 다음과 같습니다:
의미론적으로 항상 참인 단언 (Semantic always-true assertions). assert response is not None은 구문적으로 유효하며, Pylint는 반환 타입(return type)을 알지 못합니다. 실제로 실행되어 반환 값을 확인하는 테스트만이 이 단언(assertion)이 취약하다는 것을 알려줄 수 있습니다.
모킹 (Mock) 과다 사용. "이 테스트가 테스트해야 할 대상을 모킹하고 있다"라는 정적 규칙은 존재하지 않습니다. 이를 탐지하려면 테스트 대상 코드(code under test)와 대비되는 테스트의 의도를 이해해야 합니다.
단언 (Assertion) 누락. 모킹 설정만 있고 assert가 없는 테스트 함수는 린터(linter)에게 구조적으로 유효해 보입니다. Pylint는 이를 플래그(flag)로 표시하지 않습니다.
이러한 공백을 메우기 위해 두 가지 도구가 BrassCoders를 잘 보완해 줍니다. mutmut (Python용 변이 테스트 (mutation testing))와 cosmic-ray는 모두 프로덕션 코드에 작고 의도적인 변경을 도입한 후 테스트 스위트(test suite)를 다시 실행합니다. 변이(mutation) 후에도 테스트가 통과한다면, 해당 동작은 보호되지 않는 것입니다. 변이 테스트와 함께 pytest --cov --branch를 실행하면 어떤 라인이 실행되었고, 어떤 행동 변화를 테스트가 실제로 잡아낼 수 있는지에 대한 통합된 그림을 얻을 수 있습니다.
BrassCoders와 변이 테스트는 서로 다른 질문에 답합니다. send_notification 함수 내의 보안 문제는 테스트 스위트의 품질과 상관없이 BrassCoders가 프로덕션 코드에서 찾아낼 것입니다. 반면, 해당 함수의 회귀(regression)가 향후 테스트 실행에서 포착될지 여부는 변이 테스트가 답해주는 문제입니다. 이 둘은 서로 중복되지 않습니다.
OSS 코어를 무료로 설치하려면: pip install brasscoders를 실행한 다음, brasscoders scan /path/to/project를 입력하세요. 일반적인 Django 앱에서 약 90초가 소요되며, AI 어시스턴트가 직접 읽을 수 있는 YAML 파일을 생성합니다.
전체 설치 지침은 coppersun.dev/install/에서 확인할 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기