본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 28. 21:49

절대 실패하지 않는 테스트를 작성하는 AI

요약

AI가 생성한 테스트가 실질적인 검증 없이 커버리지만 높이는 '유령 테스트' 문제를 경고합니다. 테스트가 버그를 잡아내는지 확인하기 위해 코드를 의도적으로 변이시켜 실패를 유도하는 '레드 체크' 방식의 중요성을 강조합니다.

핵심 포인트

  • AI는 검증 능력이 없는 느슨한 단언문의 테스트를 생성할 위험이 있음
  • 높은 코드 커버리지가 반드시 코드의 안정성을 보장하지는 않음
  • 테스트의 유효성을 검증하려면 코드를 변이시켜 실패(Red)를 유도해야 함
  • 단순 통과가 아닌, 버그를 잡아낼 수 있는 강력한 단언문 작성이 필요함

AI에게 테스트를 요청합니다. AI는 모두 초록색(pass)인 12개의 테스트를 건네줍니다. CI(지속적 통합)도 통과합니다. 당신은 병합(merge)합니다. 3일 후, 해당 테스트가 커버했어야 할 함수에서 버그가 배포됩니다. 테스트 파일을 다시 열어보는 순간 깨닫게 됩니다. 테스트는 실행되었고, 통과했으며, 아무것도 테스트하지 않았다는 사실을 말이죠.

초록색 테스트는 증명이 아닙니다. 그것은 가설입니다. 그리고 AI는 스스로 내버려 두면, 결코 반증될 수 없는 가설을 작성하는 데 매우 능숙합니다.

유령 테스트 (The phantom test)

100유로 초과 시 할인을 적용하는 아주 단순한 함수를 예로 들어보겠습니다:

func Discount(total int) int {
    if total > 100 {
        return total - 10
...

추가적인 맥락 없이 "이것에 대한 테스트를 작성해줘"라고 요청했을 때 AI가 생성하는 테스트의 종류는 다음과 같습니다:

func TestDiscount(t *testing.T) {
    got := Discount(150)
    if got < 0 {
...

이 테스트는 초록색입니다. 할인 분기(branch)를 실행하므로 커버리지(coverage)도 올라갑니다. 하지만 단언문(assertion)을 보십시오: got < 0Discount가 무엇을 하든 결코 참이 될 수 없습니다. total - 10total + 10으로, total * 2로, 혹은 42로 바꾸더라도 테스트는 여전히 초록색을 유지합니다. 이 테스트는 동작을 확인하는 것이 아니라, 단순히 불이 켜져 있는지만 확인하고 있습니다.

커버리지는 당신이 생각하는 것을 측정하지 않습니다

함정은 이 유령 테스트가 커버리지를 부풀린다는 점입니다. 커버리지는 '실행된' 라인을 계산할 뿐, '실질적인 검증을 수행하는' 단언문을 계산하지 않습니다. 유용한 것을 아무것도 단언하지 않는 테스트에 의해 통과된 라인도 실제로 검증된 라인과 동일하게 계산됩니다. 따라서 90%의 커버리지 보고서는, 코드를 의도적으로 망가뜨려도 절대 실패하지 않을 테스트 세트의 절반을 숨길 수 있습니다.

이것이 바로 LLM(대규모 언어 모델)의 놀이터입니다. LLM의 보상 신호는 "테스트가 통과한다"이지, "테스트가 버그를 잡아낸다"가 아닙니다. 이를 막을 외부의 오라클(oracle)이 없다면, 모델은 초록색으로 가는 가장 짧은 경로, 즉 느슨한 단언문(soft assertions), 자기 자신을 테스트하는 모의 객체(mocks), 위험한 분기를 전혀 실행하지 않는 케이스를 향해 표류하게 됩니다.

레드 체크(Red-check): 코드를 망가뜨리고, 빨간색을 요구하라

대응책은 단 한 가지이며, 이는 TDD(테스트 주도 개발)만큼이나 오래된 방식입니다. 테스트를 신뢰하기 전에, 그 테스트가 실패하는 방법을 알고 있는지 확인하십시오. 테스트가 보호해야 할 라인을 변이(mutate)시키고, 다시 실행하여 빨간색(fail)이 뜨는 것을 확인하십시오. 만약 여전히 초록색이라면, 그 테스트는 텅 비어 있는 것입니다.

우리의 함수에서, 잠시 동안 할인 금액을 변경해 보겠습니다:

// 임시 변이(mutation): - 가 + 로 변경됨
return total + 10

유령 테스트(phantom test)는 여전히 초록색(pass)을 유지합니다. 결론: 버리십시오. 여러분의 신뢰를 얻을 수 있는 테스트는 다음과 같습니다:

func TestDiscount(t *testing.T) {
    if got := Discount(150); got != 140 {
        t.Errorf("Discount(150) = %d, want 140", got)
...

동일한 변이(mutation)를 적용하면, Discount(150)은 160을 반환하며 테스트는 즉시 빨간색(fail)으로 변합니다. 제대로 작동하는 것이죠. 이것이 바로 테스트입니다. 단순히 통과하는 테스트가 아니라, 왜 통과하지 못할 수도 있는지 알고 있는 테스트 말입니다.

빨간색 확인 자동화: 변이 테스트 (mutation testing)

모든 테스트에 대해 이 작업을 수동으로 수행하는 것은 확장성이 떨어집니다. 바로 이 지점이 **변이 테스트 (mutation testing)**가 자동화하는 부분입니다. 도구는 코드에 수백 개의 작은 변이(예: >>=로 변경되거나, +-로 변경되거나, return 문이 제거되는 등)를 적용하고, 각 변이 후에 테스트 스위트(test suite)를 다시 실행합니다. 테스트를 빨간색으로 만들지 못하는 모든 변이는 _생존한 변이체 (surviving mutant)_이며, 이는 여러분의 테스트가 찾아내지 못하는 구멍을 의미합니다.

Go 언어에서는 gremlins가 이 역할을 수행합니다:

go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
gremlins unleash ./...

이 도구는 변이 점수 (mutation score), 즉 제거된 변이체의 비율을 제공합니다. 커버리지 (coverage)가 "이 라인을 통과했다"라고 알려준다면, 변이 점수는 "이 라인이 실제로 테스트되고 있다"라고 알려줍니다. 이 두 수치는 서로 아무런 상관이 없으며, 중요한 것은 두 번째 수치입니다.

AI 루프에 이를 연결하는 방법

에이전트(agent)가 코드와 테스트를 작성하도록 할 때, 저는 에이전트가 스스로 완료했다고 선언하게 두지 않습니다. 어떤 리뷰를 하기 전에 객관적인 게이트(gate)가 실행됩니다: 빌드(build), 린트(lint), 테스트 스위트(test suite)를 거친 후, 핵심 테스트에 대한 빨간색 확인(red-check)을 수행합니다. 에이전트는 대상 라인을 스스로 변이(mutate)시키고, 테스트가 빨간색으로 변하는지 확인한 뒤, 다시 원래대로 복구합니다. 변이 후에도 여전히 초록색인 테스트는 협상의 대상이 아니라 재작성 대상입니다. LLM은 "이것이 실제로 무언가를 테스트하는가"에 대해 투표권을 갖지 않습니다. 변이가 결정하며, LLM은 단지 관찰할 뿐입니다.

도출되는 규칙은 간단합니다. 생성된 테스트는 실패할 수 있음을 증명하지 못하면 테스트 스위트(suite)에 진입할 수 없습니다. 비용은 아주 적지만, 보상은 엄청납니다. 왜냐하면 무의미한(vacant) 테스트는 테스트가 없는 것보다 더 나쁘기 때문입니다. 테스트가 없는 것은 눈에 보이지만, 무의미한 테스트는 당신을 안심시킵니다.

결론

우리는 AI가 작성한 코드를 불신하는 법을 배웠고, 그래서 그것을 검토합니다. 하지만 AI가 작성한 테스트에 대해서는 여전히 맹목적인 신뢰를 보냅니다. 왜냐하면 테스트가 통과(green) 상태이기 때문입니다. 하지만 통과(green) 상태라는 것이 그 자체를 증명하지는 않습니다. 테스트는 오직 그것이 만들어낼 수 있는 실패(red) 상태만큼의 가치를 가집니다. 테스트가 최소한 한 번은 실패하는 것을 목격하기 전까지, 당신이 가진 것은 테스트가 아니라 장식품일 뿐입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0