AI가 생성한 테스트는 버그와 동의할 수 있다
요약
AI 코딩 에이전트가 생성한 테스트는 독립적인 검증처럼 보이지만, 실제로는 구현과 동일한 컨텍스트와 가정을 공유하여 같은 실수를 반복할 위험이 있습니다. 특히 테스트 케이스가 비즈니스 요구사항의 근본적인 오류를 잡아내지 못하고, 단순히 코드가 자신에게서 주장하는(asserting) 동작을 재현하는 경우에 취약합니다.
핵심 포인트
- AI 에이전트의 구현과 테스트는 동일한 가정을 공유할 수 있습니다.
- 테스트가 통과한다고 해서 비즈니스 로직 오류가 없다는 보장은 아닙니다.
- 코드 커버리지(Code coverage)만으로는 충분하지 않으며, 오라클(oracle) 기반 검증이 필요합니다.
- AI 에이전트의 테스트는 독립적인 증거로 취급해서는 안 됩니다.
AI 코딩 에이전트가 기능을 구현하고, 테스트를 생성하며, 이를 실행한 후, 녹색 빌드(green build) 상태로 풀 리퀘스트(pull request)를 엽니다.
이는 독립적인 검증처럼 보입니다. 하지만 종종 그렇지 않습니다.
만약 구현과 테스트가 동일한 프롬프트, 저장소 컨텍스트(repository context), 그리고 요구사항 해석에서 나왔다면, 이들은 같은 실수를 반복할 수 있습니다. 코드는 잘못되었습니다. 테스트는 그 잘못된 동작이 정확히 구현된 대로 작동한다는 것을 자신감 있게 증명합니다.
실패는 AI가 테스트를 작성할 수 없다는 것이 아닙니다. 실패는 상관관계가 있는 출력을 독립적인 증거로 취급하는 것입니다.
테스트가 통과한 이유는 가정을 복사했기 때문이다
다음의 청구 요구사항을 상상해 보세요:
주문 소계에 20% 할인을 적용한 다음, 할인된 금액에 세금을 추가하세요.
의도된 계산은 다음과 같습니다:
subtotal = 100.00
discount = 20.00
taxable amount = 80.00
...
에이전트가 요구사항을 잘못 읽어 세금 부과 후 할인을 적용합니다:
export function calculateTotal(subtotal, taxRate, discountRate) {
const tax = subtotal * taxRate;
const discount = subtotal * discountRate;
...
위의 간단한 값들에 대해 두 공식 모두 우연히 88.00을 반환합니다. 생성된 테스트는 통과합니다:
import { describe, expect, it } from "vitest";
import { calculateTotal } from "./billing.js";
...
테스트는 합리적으로 보입니다. 심지어 구체적인 예상 값(expected value)까지 사용합니다.
하지만 의도된 공식과 잘못된 공식을 구분하지 못합니다.
이제 고정 할인, 세금 면제, 관할권별 반올림, 또는 다른 세금 등급을 가진 품목들을 도입해 보세요. 공유된 가정이 프로덕션(production) 청구 버그가 됩니다.
커버리지는 오라클이 아니다
코드 커버리지(Code coverage)는 다음과 같은 질문에 답합니다:
- 이 라인이 실행되었나요?
- 이 분기(branch)가 방문되었나요?
- 이 함수가 호출되었나요?
하지만 다음 질문에는 답하지 못합니다:
- 예상 값이 올바른가요?
- 테스트가 제품 요구사항을 인코딩했나요?
- 그럴듯하게 잘못된 구현에 대해 테스트가 실패할까요?
- 시스템이 비즈니스 불변량(business invariant)을 보존했나요?
생성된 테스트 스위트는 구현의 현재 동작을 자신에게서 주장(asserting)하면서 100% 라인 커버리지를 달성할 수 있습니다.
이 패턴은 특히 취약합니다:
const result = calculateTotal(order);
expect(result.subtotal).toBe(order.subtotal);
...
테스트는 구현에 사용된 동일한 공식을 재구성합니다. 만약 그 공식이 잘못되었다면, 양쪽 모두 함께 틀리게 됩니다.
테스트에는 오라클(oracle)이 필요합니다: 예상 결과가 올바르다고 믿을 수 있는 독립적인 이유 말입니다.
공유된 컨텍스트는 상관관계 오류를 만든다
하나의 에이전트가 같은 세션에서 코드와 테스트를 작성할 때, 두 아티팩트는 다음 사항들을 상속받을 수 있습니다:
- 동일한 모호한 요구사항
- 동일한 불완전한 리포지토리 컨텍스트
- 동일한 잘못된 도메인 가정
- 동일한 누락된 엣지 케이스(edge case)
- 동일한 명명 기반 해석
- 동일한 구식 예시
같은 대화에서 테스트를 재생성한다고 해서 의미 있는 독립성을 추가할 수 없습니다. 모델은 여전히 자신이 이전에 추론하고 구현했던 것을 볼 수 있습니다.
심지어 두 번째 모델을 사용하더라도 자동으로 충분하지 않습니다. 만약 두 모델 모두 동일한 모호한 프롬프트를 받고 같은 구현으로부터 기대를 도출한다면, 그들은 동일한 매력적인 실수에 수렴할 수 있습니다.
목표는 단순히 다른 생성기가 아닙니다. 목표는 다른 진실의 원천(source of truth)입니다.
위험한 테스트 패턴들
1. 구현을 자신에게 주장하기
expect(formatPrice(input)).toBe(formatPrice(input));
이 극단적인 예시는 명백하지만, 간접적인 버전들이 흔합니다. 테스트는 프로덕션 경로와 동일한 헬퍼(helper), 파서(parser), 매퍼(mapper) 또는 공식을 호출할 수 있습니다.
2. 알 수 없는 동작을 스냅샷 찍기
expect(calculateInvoice(order)).toMatchSnapshot();
첫 번째 스냅샷은 구현이 무엇을 생성했는지 기록합니다. 이를 승인하는 것은 누군가가 그 출력을 받아들였다는 것을 증명할 뿐, 그 출력이 요구사항과 일치한다는 것을 증명하지 못합니다.
스냅샷은 변경 사항을 감지하는 데 유용합니다. 아무도 그 의미를 검증하지 않는 경우 초기 정확성을 확립하는 데는 취약합니다.
3. 내부 구현 세부 사항 미러링하기
expect(cache.internalEntries.size).toBe(1);
이 테스트는 관찰 가능한 동작보다는 구조를 확인합니다. 사용자가 오래된 데이터(stale data)를 받는 상황에서도 통과할 수 있으며, 안전한 리팩토링 과정에서 실패할 수도 있습니다.
4. 프롬프트의 예시만 테스트하기
이슈에 하나의 예시가 포함되어 있다면, 에이전트는 종종 이를 하나의 성공적인 경로(happy-path) 테스트로 재현합니다. 그 예시는 경쟁하는 해석들을 구별하지 못할 수 있습니다.
5. 실패를 모킹 처리하기
paymentGateway.charge = vi.fn().mockResolvedValue({ status: "paid" });
이 테스트는 항상 즉시 성공하는 의존성(dependency)에 대한 동작을 증명할 뿐입니다. 타임아웃, 중복 응답, 불확실한 결과 또는 원격 측에서 청구 처리가 된 후의 재시도에 대해서는 아무것도 말해주지 않습니다.
간단한 복습 질문 생성된 모든 단언(assertion)에 대해 다음을 물어보세요: > 이 예상 값은 어디서 왔습니까? 만약 답이 "구현에서", "에이전트의 이전 출력에서", 또는 "우리가 검사하지 않은 스냅샷에서"라면, 그 테스트는 중요한 동작에 충분히 독립적이지 않습니다.
예시가 아닌 불변성(Invariants)부터 시작하기
예시는 선택된 지점들을 검증합니다. 불변성은 많은 입력값에 걸쳐 유지되어야 하는 속성을 설명합니다.
할인 계산의 경우, 유용한 불변성에는 다음이 포함될 수 있습니다:
- 할인이 세금 공제 전 금액을 증가시킬 수 없다
- 0% 할인은 아무것도 변경하지 않는다
- 100% 할인은 과세 대상 금액을 생성하지 않는다
- 총액은 음수가 될 수 없다
- 항목 순서를 변경해도 총액이 변하지 않는다
- 반올림은 비즈니스 규칙에 의해 정의된 경계에서 발생한다
속성 기반 테스트(Property-based tests)는 생성된 입력값 전반에 걸쳐 이러한 규칙들을 실행할 수 있습니다:
import fc from "fast-check";
import { expect, it } from "vitest";
import { calculateTotal } from "./billing.js";
...
하지만 이것이 모든 도메인 규칙을 증명하는 것은 아닙니다. 하지만 하나의 편리한 예시가 깨진 관계를 숨기는 것을 더 어렵게 만듭니다.
별도의 출처에서 인수 테스트(Acceptance Tests) 파생하기
가장 강력한 인수 테스트(acceptance tests)는 구현과 독립적으로 존재했던 아티팩트에서 나와야 합니다:
- 제품 인수 기준(product acceptance criteria)
- API 계약(API contracts)
- 규제 규칙(regulatory rules)
- 프로토콜 사양(protocol specifications)
- 이전에 검증된 동작(previously verified behavior)
- 도메인 전문가가 승인한 프로덕션 예시
- 다른 구현체로부터의 호환성 픽스처(compatibility fixtures)
청구서(billing) 예시를 위해, 에이전트에게 구현을 요청하기 전에 결정 테이블(decision table)을 만드세요:
Case: 세금 부과 전 할인율 적용 (percentage discount before tax)
Subtotal: 125.00
Discount: 20%
...
그런 다음, 이 독립적으로 검토된 픽스처를 테스트로 변환하세요:
it("할인율 적용 후 금액에 세금을 부과하는지", () => {
expect(calculateTotal(125, 0.08, 0.2)).toBe(108);
});
여기서 특이한 값들은 의도적입니다. 이 값들은 우연히 단순한 입력값에 대해 일치하는 공식들을 구별해줍니다.
테스트가 구현체와 싸우게 만들기 (Make the Test Fight the Implementation)
뮤테이션 테스트(Mutation testing)는 코드를 작은 방식으로 변경하고, 그 테스트들이 실패하는지 확인합니다.
예를 들어, 뮤테이션 도구는 다음을 대체할 수 있습니다:
const taxableAmount = subtotal - discount;
다음으로 이것으로 변경하거나:
const taxableAmount = subtotal + discount;
또는 다음을 변경합니다:
if (total >= freeShippingThreshold)
다음과 같이:
if (total > freeShippingThreshold)
만약 테스트 스위트가 녹색(green) 상태를 유지한다면, 그 동작을 보호하지 못했다는 의미입니다.
뮤테이션 점수(mutation score)가 완벽한 품질 측정 지표는 아닙니다. 동등 뮤테이션(Equivalent mutations)과 비용이 많이 드는 테스트 실행은 실제 제약 사항입니다. 하지만 타겟팅된 뮤테이션 테스트는 권한 부여(authorization), 청구서, 재시도(retries), Idempotency, 제한(limits) 등 우연히 발생하는 한 글자의 오류가 중요한 로직에 유용합니다.
Meta는 Just-in-Time(JIT) 테스트에 대한 2026년 연구에서 관련 변경 인식 접근 방식을 보고했습니다. 단순히 제안된 코드에서 통과하는 테스트만 생성하는 대신, 이 포착 테스트(catching tests)는 변경 사항이 적용되기 전에 회귀(regressions)를 표면화하도록 설계되었습니다. 22,126개의 생성된 테스트에 대한 평가에서, 변경 인식 방법은 후보 포착 생성을 개선했으며, 보고된 프로세스는 확인된 심각한 실패를 발견했습니다. 중요한 아이디어는 실용적입니다: 생성된 테스트는 단순히 변경 사항을 문서화하는 것이 아니라 능동적으로 그 변경 사항을 반증하려고 노력해야 합니다.
두 개의 구현체가 있을 때 차분 테스트(Differential Testing) 사용하기
차분 테스트는 동일한 입력을 두 개의 독립적인 구현체에 통과시키고 출력을 비교합니다.
이는 재작성(rewrites), 마이그레이션(migrations), 최적화(optimizations) 중에 유용합니다:
for (const order of generatedOrders) {
const legacyResult = legacyCalculator(order);
const newResult = newCalculator(order);
...
참조 구현체는 빠르거나 우아할 필요가 없습니다. 비교되는 동작에 대해 신뢰성이 있어야 합니다.
주의하세요: 레거시 동작이 알려진 버그를 포함하는 경우, 이러한 예외를 명시적으로 인코딩해야 합니다. 그렇지 않으면 차분 테스트는 스냅샷(snapshots)처럼 결함을 보존할 수 있습니다.
워크플로우에서 역할 분리하기
더 안전한 AI 지원 워크플로우는 구현과 검증 사이에 마찰을 만듭니다.
단계 1: 계약 추출하기 (Extract the contract)
코드를 생성하기 전에 다음 사항들을 문서로 작성하세요:
- 입력 및 출력(inputs and outputs)
- 불변성(invariants)
- 경계 조건(boundary conditions)
- 오류 동작(error behavior)
- 보안 제약 조건(security constraints)
- 그럴듯한 해석을 구별하는 예시들
단계 2: 구현체 생성하기 (Generate the implementation)
코딩 에이전트에게 계약을 제공하되, 그 구현체가 기대 값의 출처가 되도록 두지 마세요.
단계 3: 새로운 컨텍스트에서 적대적 테스트 생성하기 (Generate adversarial tests in a fresh context)
다른 에이전트나 세션에 다음을 제공하세요:
- 계약(the contract)
- 코드 차분(the code diff)
- 공개 인터페이스(public interfaces)
- 알려진 실패 클래스(known failure classes)
그것에게 커버리지를 개선하라고 요청하는 것이 아니라, 반례(counterexamples)를 찾도록 요청하세요.
유용한 프롬프트는 다음과 같습니다:
Act as an adversarial test engineer.
Given the requirement and code diff:
...
4단계: 타겟 변이 테스트 실행 (Run targeted mutation testing)
모든 커밋마다 전체 저장소(repository)를 변이시키기보다는 중요한 결정 사항에 집중하세요.
5단계: 오라클 검토 (Review the oracle)
검토자들은 테스트가 읽기 쉬운지 여부를 묻는 데 시간을 덜 쓰고, 다음 질문을 하는 데 더 많은 시간을 할애해야 합니다:
- 이 예상 값(expected value)이 왜 정확한가?
- 이 테스트는 어떤 잘못된 구현(wrong implementation)을 거부할 것인가?
- 이 테스트는 프로덕션 헬퍼(production helpers)에 의존하지 않는가?
- 정확히 경계값(boundary)에서 무슨 일이 발생하는가?
- 어떤 요구사항이 여전히 테스트되지 않았는가?
6단계: 병합 제어 유지 (Keep merge controls)
녹색의 에이전트 생성 풀 리퀘스트(agent-generated pull request)라 할지라도 저장소 보호 기능(repository protections), 필수 검사(required checks), 그리고 인간의 검토가 필요합니다. GitHub는 코딩 에이전트가 만든 초안 풀 리퀘스트를 병합하기 전에 명시적으로 인간 검토를 요구하며, 생성된 변경 사항 주변에 보안 스캐닝과 브랜치 제한을 계층화합니다.
이는 형식적인 절차가 아닙니다. 이는 생성된 코드와 생성된 테스트가 권위(authority)가 아니라 증거(evidence)임을 인식하는 것입니다.
실제 검토 예시 (A Practical Review Example)
에이전트가 다음과 같은 속도 제한기(rate limiter)를 추가한다고 가정해 봅시다:
export function isAllowed(requestCount, limit) {
return requestCount <= limit;
}
또한 다음을 생성합니다:
it("allows requests within the limit", () => {
expect(isAllowed(3, 5)).toBe(true);
});
...
두 테스트 모두 통과합니다. 커버리지(Coverage)가 완전할 수도 있습니다.
하지만 중요한 질문은 다섯 번째 요청이 허용되는지 아니면 limit이 이미 소모된 요청 수를 나타내는지입니다. 이 테스트들은 해석이 중요한 정확한 경계값(exact boundary)을 회피합니다.
구별하는 케이스를 추가하세요:
it("defines behavior at the exact limit", () => {
expect(isAllowed(5, 5)).toBe(false);
});
false가 정확한지는 계약(contract)에 달려 있습니다. 핵심은 인간 또는 독립적인 명세(specification)가 이를 결정해야 한다는 것입니다. 구현 자체가 자신의 요구사항을 결정할 수는 없습니다.
실행 가능한 체크리스트 (Actionable Checklist)
AI 생성 테스트를 신뢰하기 전에 다음 사항들을 확인하세요:
- 예상 값은 명세(specification), 고정값(fixture), 참조 구현(reference implementation) 또는 검토된 계산에서 비롯됩니다.
- 테스트는 자체 기대값을 계산하기 위해 프로덕션 헬퍼를 호출하지 않습니다.
- 경계값(Boundary values)이 명시적으로 테스트됩니다.
- 최소한 하나의 케이스가 요구사항의 그럴듯한 해석을 구별합니다.
- 중요한 동작은 가능한 경우 불변식(invariant)으로 표현됩니다.
- 스냅샷(Snapshots)은 승인 전에 의미를 위해 검사되었습니다.
- 목(Mocks)에는 실패, 지연, 재시도 및 불확실한 결과가 포함됩니다.
- 생성된 테스트는 변경 사항을 확인하는 것뿐만 아니라 반증하려고 시도합니다.
- 중요한 로직은 표적 변이 테스트(targeted mutation testing)를 받습니다.
- 재작성은 적절한 경우 신뢰할 수 있는 구현과 비교됩니다.
- 검토자는 커버리지뿐만 아니라 테스트 오라클(test oracle)을 검사합니다.
- 에이전트가 생성한 풀 리퀘스트는 필요한 검사와 인간의 검토를 받습니다.
CTA: 현재 프로젝트에서 AI가 생성한 테스트 중 하나를 선택하고 모든 예상 값을 그 출처까지 추적하십시오. 만약 흔적이 구현 자체에서 끝난다면, 해당 단언(assertion)을 독립적인 고정값, 불변식 또는 경계 케이스로 대체하십시오.
핵심 요약
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기