필요한 동작을 증명하지 않고도 테스트가 통과할 수 있는 이유
요약
AI 에이전트가 코드를 작성하는 환경에서 발생할 수 있는 '약한 단언(Weak assertions)'의 위험성을 경고합니다. 테스트가 단순히 값의 존재 여부만 확인하고 실제 비즈니스 로직의 정확성을 검증하지 못할 때 발생하는 신뢰성 문제를 다룹니다.
핵심 포인트
- AI 에이전트가 구현과 테스트를 동시에 작성할 경우 잘못된 기대값이 복사될 위험이 있음
- 단순히 null이 아님을 확인하는 '존재 확인' 방식의 테스트는 논리적 오류를 잡아내지 못함
- 테스트 통과가 반드시 소프트웨어의 신뢰성을 보장하는 것은 아님을 인지해야 함
- 에이전트가 생성한 방대한 테스트 코드에 대한 인간의 검토 역량이 중요함
동작이 아닌 연결(wiring)만을 검증하는 약한 단언(Weak assertions)과 모크(mocks)는 여전히 머지(merge)될 수 있습니다.
에이전트(Agents)가 내 저장소(repositories)의 모든 코드를 작성하고 나는 무엇을 포함할지 결정하기 때문에, 내가 읽는 모든 테스트는 내가 작성하지 않은 테스트들입니다. 테스트들은 변경 사항과 함께 이미 통과된 상태로 도착하며, 그 양은 매우 많습니다.
그러던 중 한 테스트에서 다음과 같은 내용을 발견했습니다:
require.NotNil(t, invoice)
테스트는 거기서 멈춥니다. 인보이스(invoice)의 총액이나 품목(line items)을 전혀 확인하지 않습니다. 모든 잘못된 총액이 올바른 총액만큼이나 쉽게 해당 라인을 만족시켜 버립니다.
단언(assertion)이 실제로 무엇을 확인했는가?
테스트 결과는 실제입니다. 단언(assertion)은 유지되었습니다. 하지만 Google의 SRE book은 그 한계에 대해 명확히 설명합니다: "테스트 또는 일련의 테스트를 통과하는 것이 반드시 신뢰성(reliability)을 증명하는 것은 아닙니다. 하지만 일반적으로 실패하는 테스트는 신뢰성의 부재를 증명합니다."
그 "일반적으로(generally)"라는 단어가 중요합니다. 실패한 테스트는 불안정한(flaky) 머신, 결함이 있는 테스트, 또는 업데이트되지 않은 기대치(expectation)로 인해 발생할 수 있습니다. 통과된 단언(assertion)은 그것이 무엇을 확인했는지는 정확히 알려줍니다. 하지만 그 확인이 요구 사항에 충분히 강력했는지 여부는 알려주지 않습니다.
테스트 러너(test runner)는 두 가지 질문에 답합니다: 코드가 실행되었는가, 그리고 단언(assertions)을 만족했는가? 만약 단언이 null이 아닌 모든 것을 허용한다면, 테스트는 단지 인보이스가 null이 아니라는 사실만을 알려줄 뿐입니다. 총액, 통화(currency), 또는 품목(line items)을 확인하지 않습니다.
에이전트에게 테스트를 추가하라고 요청하면, 에이전트는 단언(assertions)이 포함된 테스트를 추가하고 테스트 스위트(test suite)는 통과합니다.
이러한 패턴 중 새로운 것은 없습니다. 약한 단언(Weak assertions), 모크 남용(mock abuse), 그리고 빈 테스트 본문(empty test bodies)은 에이전트가 등장하기 수십 년 전부터 테스트 서적에 있었습니다. 두 가지가 변했습니다. 동일한 에이전트가 구현(implementation)과 그 실수를 잡아내야 할 테스트를 모두 작성한다는 점입니다. 테스트는 구현의 실수를 기대값(expected value)에 그대로 복사하거나, 필요한 동작을 확인하지 않고도 통과하는 단언을 사용할 수 있습니다. 또한 에이전트는 리뷰가 따라잡을 수 없을 정도로 빠르게 테스트를 작성하므로, 당신은 그중 아주 적은 부분만을 읽게 됩니다.
존재 확인(existence check)은 모든 오답을 수용합니다
모든 언어는 값이 존재하는지만 확인하는 어설션 (assertion)을 제공합니다:
invoice, err := billing.Invoice(ctx, order.ID)
require.NoError(t, err)
require.NotNil(t, invoice)
다음과 같은 경우에도 테스트는 여전히 통과합니다: 합계가 0이거나, 통화가 틀리거나, 품목(line items)이 없거나, 오래된 주문 데이터로 생성된 인보이스인 경우입니다. 이 테스트는 null 값만을 잡아냅니다. 목록에 있는 다른 모든 결과는 여전히 통과합니다.
발견하기 더 어려운 사례는 다음과 같습니다:
const invoice = await buildInvoice(order);
expect(invoice).toEqual({
total: invoice.total,
...
기대값 (expected value)을 결과값으로부터 읽어오고 있습니다. 결과가 무엇이든 간에 그것을 기대하는 테스트는 검증(check)이 아니라 거울(mirror)입니다. 잘못된 합계가 나와도 테스트는 실패하지 않습니다. 에이전트(agent)가 정답이 무엇인지 모를 때 이런 코드를 작성하곤 합니다. 기대값은 결과가 아니라 요구사항 (requirement)으로부터 와야 합니다.
모의 객체 (Mock)는 호출은 증명할 수 있지만, 기대하는 결과는 놓칠 수 있습니다
mailer = Mock()
ledger = Mock()
service = Billing(mailer, ledger)
...
이 테스트는 send와 record가 각각 한 번씩 호출되었음을 증명합니다. 하지만 수신자, 인보이스, 또는 원장 (ledger) 항목은 확인하지 않습니다.
서비스가 확정된 인보이스 대신 초안 인보이스를 보내더라도 테스트는 여전히 통과합니다. 서비스가 인보이스를 잘못된 주소로 보내더라도 통과합니다. 인보이스 합계가 0이라도 통과합니다. 이 테스트는 객체들이 서로 연결되어 있는지는 확인하지만, 요구사항은 고객에게 발송된 인보이스에 관한 것입니다.
모킹 (Mocking) 자체가 문제는 아닙니다. 메일 제공자와 원장은 단위 테스트 (unit test)가 모킹해야 하는 정확한 의존성 (dependencies)입니다. 아무도 실제 SMTP 서버에 접속하는 단위 테스트를 원하지 않습니다. 결함은 어설션 (assertion)에 있습니다. assert_called_once는 상호작용 (interaction) 단계에서 멈춥니다. 수신자, 인보이스, 또는 원장 항목을 전혀 읽지 않습니다.
테스트가 모든 것을 실행하고도 아무것도 검증하지 않을 수 있습니다
func TestFinalizeInvoice(t *testing.T) {
order := seedOrder(t, 3)
svc := billing.New(t)
...
테스트는 주문을 생성하고 Finalize를 호출합니다. 실행된 모든 라인은 커버리지 (coverage)에 포함되지만, 테스트는 아무것도 단언 (assert)하지 않습니다. 만약 Finalize가 잘못된 총액을 작성하더라도 이 테스트는 통과합니다. 만약 인보이스 (invoice)를 전혀 작성하지 않더라도 이 테스트는 통과합니다. 테스트가 종료된 후에도 작업은 계속 실행될 수 있으며, 이 테스트는 여전히 통과할 것입니다.
이러한 문제는 의도적으로 찾으려 할 때는 쉽게 발견되지만, 40개의 파일이 포함된 디프 (diff) 내에서는 거의 보이지 않습니다. 왜냐하면 마치 완성된 테스트처럼 읽히기 때문입니다.
단언 (assertion)을 문자 그대로 읽고, 이를 깨뜨려 보십시오
중요한 모든 테스트에 대해 다음 다섯 가지 질문을 순서대로 던지십시오.
- 필요한 동작을 한 문장으로 기술하십시오. 만약 할 수 없다면, 아직 테스트가 문제는 아닙니다.
- 그것을 증명할 수 있는 관찰 가능한 결과 (observable outcome)를 명시하십시오. 반환 값 (returned value), 저장된 행 (stored row), 방출된 이벤트 (emitted event), 또는 에러 (error) 등이 될 수 있습니다.
- 단언 (assertion)을 문자 그대로 읽고, 어떤 잘못된 결과가 여전히 통과할지 질문하십시오. 그 결과들을 적어보십시오. 그 목록이 바로 해당 테스트가 허용하고 있는 것들을 보여줍니다.
- 모의 객체 (mocks)에 대한 단언이 실제로 무엇을 증명하는지 확인하십시오. 의존성 (dependency)을 격리하는 것은 좋습니다. 하지만 모든 단언이 단순히 호출을 받았는지 확인하는 수준에서 멈춘다면, 기대하는 동작은 여전히 검증되지 않았을 수 있습니다.
- 가장 작은 부정적 케이스 (negative case) 또는 경계값 케이스 (boundary case)를 추가하십시오. 동작이 잘못되었을 때 테스트가 반드시 실패하는지 확인하십시오.
세 번째 질문이 가장 어려운 부분입니다. 테스트 스위트 (suite)는 이미 통과하고 있습니다. 당신은 단언 (assertions)이 놓친 것이 무엇인지 찾아내야 합니다.
인보이스 (invoice)에 대해 실행해 봅시다. 테스트는 완료된 주문이 기대하는 인보이스를 반환하고, 초안(draft) 상태의 주문은 에러를 반환한다는 것을 증명해야 합니다.
invoice, err := billing.Invoice(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, int64(4250), invoice.TotalCents)
...
마지막 두 줄은 제가 가장 강력하게 주장하는 부분입니다. "여기에 대한 테스트를 추가하라"는 말 속에는 소프트웨어가 거절해야 하는 상황에 대한 요구사항은 포함되어 있지 않습니다. NIST의 개발자 검증 가이드라인은 해당 사례를 명세(specification)와 동일한 목록에 포함합니다. 즉, 블랙박스 테스트 케이스(black box test cases)는 "기능 명세 또는 요구사항, 부정 테스트 (invalid inputs 및 소프트웨어가 하지 말아야 할 일을 테스트하는 것)... 입력 경계 분석(input boundary analysis), 그리고 입력 조합(input combinations)"에서 도출됩니다.
만약 단언(assertion)이 요구되는 동작과 그럴듯하게 틀린 결과(plausible wrong result)를 구분할 수 없다면, 그 테스트는 완료된 것이 아닙니다.
뮤테이션 테스트(Mutation testing)는 테스트가 놓친 것을 찾아냅니다
뮤테이션 테스트(Mutation testing)는 동일한 연습 과정을 자동화합니다. Stryker는 이를 명확하게 설명합니다: "뮤테이션 테스트는 코드에 변경 사항을 도입한 다음, 변경된 코드를 대상으로 유닛 테스트(unit tests)를 실행합니다." 이는 구현(implementation)을 변경하고, 테스트 스위트(test suite)를 실행하며, 테스트가 잡아내지 못한 변경 사항을 보고합니다. Gremlins는 Go를 위해, mutmut는 Python을 위해, Stryker는 TypeScript를 위해, 그리고 cargo-mutants는 Rust를 위해 이 기능을 제공합니다.
가능한 곳이라면 뮤테이션 테스트를 사용하세요. 이는 사용자가 수동으로 하는 것보다 더 많은 잘못된 변경 사항을 시도할 것입니다. 모든 변이체(mutant)에 대해 테스트를 다시 실행하므로, 대규모 뮤테이션 세트는 시간과 컴퓨팅 자원을 많이 소모합니다.
생존한 변이체(surviving mutant)는 테스트 스위트가 해당 코드 변경을 잡아내지 못했음을 보여줍니다. 뮤테이션 테스트는 취약한 테스트를 찾아낼 수는 있지만, 기대되는 결과(expected result)가 무엇이어야 하는지는 알려줄 수 없습니다. 그것은 반드시 요구사항(requirement)으로부터 나와야 합니다.
규칙이 결정할 수 있는 부분을 위해 Mindrealm을 구축했습니다
Mindrealm의 체크 항목 중 4개는 테스트 파일을 직접 읽습니다. 하나는 단언(assertion)이나 검증(verification)이 전혀 없는 테스트 함수에서 작동하며, 하나는 거의 모든 값을 허용하는 단언에서 작동합니다. 세 번째는 테스트의 단언이 테스트 대상 동작(behavior under test)이 아닌 모의 객체(mock)에 관한 것일 때 작동하며, 네 번째는 이유를 명시하지 않고 테스트가 건너뛰어질(skipped) 때 작동합니다. 이들은 Go, Python, TypeScript, Rust에서 실행되며, 동일한 코드는 실행할 때마다 동일한 결과(findings)를 생성합니다.
Mindrealm은 require.NotNil이 너무 많은 것을 허용한다는 사실을 알려줄 수 있습니다. 하지만 해당 요구사항이 저장소(repository)에서 누락되었을 때, 합계가 4,250이어야 했다는 사실은 알려줄 수 없습니다. 소스 코드를 읽는 것과 코드를 실행하는 것은 서로 다른 질문에 답하며, 동일한 NIST 가이드라인은 두 가지 모두를 검증(verification)으로 간주합니다: "검증(Verification)에는 동적 분석(dynamic analysis) 또는 프로그램 실행("좁은 의미의 테스트") 외에도 정적 분석(static analysis) 및 코드 리뷰(code review)와 같은 방법이 포함됩니다." 그 어느 것도 누락된 계약(contract)을 제공하지는 않습니다.
체크(check)는 패턴을 잡아냅니다. 당신은 여전히 그 숫자를 알고 있어야 합니다.
그것이 통과한다는 것을 당신은 이미 알고 있습니다
그것이 바로 "통과하는가"가 질문이 아닌 이유입니다. 당신이 파일을 열기 전에도 그것은 통과했으며, 당신이 승인한 후에도 통과할 것입니다.
어떤 잘못된 결과가 여전히 테스트를 통과할 수 있는지 질문해 보고, 그 목록이 얼마나 길어지는지 확인해 보십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기