본문 바로가기
프로그래밍/AI

AI가 짠 테스트는 다 통과한다 — 그게 함정이다

by me_in_sk 2026. 6. 13.
반응형
AI 페어 프로그래밍

AI가 짠 테스트는 다 통과한다 — 그게 함정이다

2026-06-13 AI에게 테스트를 맡길 때 알아야 할 것
핵심 요약
  • AI에게 "기능 + 테스트"를 시키면 테스트는 거의 항상 초록불로 돌아온다. 그런데 그 초록불은 생각보다 의미가 없다.
  • AI는 "버그를 잡는 테스트"가 아니라 "통과하는 테스트"를 짜는 경향이 있다 — 코드와 테스트를 한 번에 만들면서, 테스트가 코드를 사후에 흉내 내기 때문이다(순환). 게다가 진짜 의존성(DB·외부 서비스)을 가짜(mock)로 갈아끼운다.
  • 그래서 버그는 거의 항상 그 가짜로 치워버린 경계에 산다 — 코드와 실제 시스템이 만나는 자리.
  • 대응 세 가지: ① 테스트를 먼저 쓴다(TDD) — 순환을 끊는다. ② 경계는 진짜로 테스트한다(통합 테스트, 가짜 금지). ③ "완료"는 초록불이 아니라 실제 파이프라인을 한 번 돌려본 것으로 정의한다.

1.초록불이 알려주지 않는 것

AI에게 기능을 맡기며 "테스트도 같이 짜줘"라고 하면, 결과는 거의 항상 초록불이다. 사람은 초록불을 보면 안심한다. 문제는, AI가 짠 테스트의 초록불은 사람이 짠 테스트의 초록불과 의미가 다르다는 것이다.

사람은 보통 "이게 깨지면 안 되는데" 하는 지점을 찌르려고 테스트를 짠다. AI는 다르다. AI가 최적화하는 건 초록불 그 자체다 — 통과시키는 게 목표지, 버그를 드러내는 게 목표가 아니다. 그래서 안심하고 넘어간 자리에서 종종 운영 장애가 난다. 왜 그런지, 그게 왜 위험한지, 어떻게 막는지 순서대로 보자.

2.AI는 "통과하는 테스트"를 짠다

AI에게 코드와 테스트를 한 번에 맡기면, 테스트는 방금 만든 코드를 사후에 정당화하는 방향으로 쓰인다. 거기에 초록불로 가는 가장 빠른 길 — 손이 많이 가는 진짜 의존성을 가짜로 갈아끼우기 — 이 더해지면, 테스트는 이런 모양이 된다.

❌ 통과하지만 아무것도 검증 못 하는 테스트Python · pytest
def test_extract_referenced_models():
    # 진짜 객체 대신, "코드가 기대하는 모양"의 가짜를 손으로 만든다
    obj = SimpleNamespace(
        id="x-1",
        document={"blocks": [{"kind": "ai", "model": "m-42"}]},
    )
    assert extract_referenced_models(obj) == ["m-42"]   # ✅ green

이 테스트가 검증하는 건 함수가 아니다. 방금 손으로 만든 가짜의 모양을 검증한다. 코드도 가짜도 같은 손이 같은 가정으로 만들었으니 통과는 당연하다. 답을 보고 문제를 지어낸 셈이라, 초록불이어도 알려주는 건 없다. 이게 AI에게 "기능과 테스트"를 한 번에 맡길 때 가장 흔히 받는 결과물이다.

3.가짜는 진짜와 모양이 다르다

가짜로 때운 테스트가 위험해지는 건 가짜와 진짜의 모양이 어긋날 때다. 그리고 그 어긋남이야말로 버그의 정체인 경우가 많다.

실제로 겪은 사례 하나. 어떤 객체에서 값을 뽑는 코드에 AI가 테스트를 네 개 붙였고, 전부 통과했다. 그런데 진짜 객체는 가짜와 두 군데가 달랐다 — (1) 가짜엔 있던 필드가 진짜에선 다른 테이블에 있어 목록 쿼리가 그 필드 없는 쪽을 읽었고, (2) 가짜에선 늘 숫자였던 값이 진짜 데이터에선 가끔 null이었다. 둘 다 화면을 통째로 죽이는 버그였는데, 테스트 네 개는 전부 새파랬다.

가짜 테스트가 한 번도 밟지 않은 진짜 경로Python
# 가짜엔 있던 필드가 진짜 객체엔 없다(다른 테이블에 있음) → 접근하는 순간 깨짐
rows = await session.execute(select(Strategy))
for s in rows.scalars():
    extract_referenced_models(s)   # AttributeError: 'Strategy'에 그 필드 없음

테스트가 많을수록 안심된다고 생각하기 쉽지만, 가짜 위에 쌓은 테스트는 많을수록 더 두껍게 가린다.

4.버그는 mock으로 치운 자리에 산다

여기서 일반화할 수 있는 원칙이 하나 있다.

mock은 "이미 이해한 부분"을 치운다. 그런데 버그는 거의 항상 "잘못 이해한 부분"에 산다. 그리고 이 둘은 같은 자리다 — 코드와 실제 시스템이 만나는 경계(이 필드가 어느 테이블에 있나, 이 값이 null일 수 있나, 이 호출이 실패하면 뭐가 오나). 가짜를 만드는 순간 "이 경계는 이렇게 생겼을 것"이라는 가정이 코드로 굳는데, 그 가정이 틀린 게 바로 버그다. 그래서 가짜와 버그는 같은 가정 위에 서서 사이좋게 통과한다.

⚠ 기억할 한 줄

"안다고 생각해서" mock으로 치워버린 그 부분이 사실은 틀린 부분이다. 가짜로 대체한 경계가 곧 버그의 서식지다.

5.해법 1 — 테스트를 먼저 쓴다(TDD): 순환을 끊는다

§2의 순환(테스트가 코드를 사후에 흉내 냄)을 가장 깔끔하게 끊는 방법은 순서를 뒤집는 것이다. TDD(Test-Driven Development)의 핵심이 그거다 — 코드보다 테스트를 먼저 쓴다. 통과시킬 코드가 아직 없으니, 테스트는 "원하는 행동"을 기술할 수밖에 없다(구현을 흉내 낼 방법이 없다).

AI 페어에 적용하는 가장 안전한 형태는 이거다 — 사람이 테스트(=명세)를 먼저 쓰고, AI에게 "이걸 통과시켜"라고 구현을 맡긴다. 테스트가 코드보다 먼저 존재하니, AI가 자기 코드에 맞춰 테스트를 깎는 순환이 원천 차단된다.

먼저 "행동"을 박는다 → 그 다음 AI에게 구현을 맡긴다Python · pytest
# 1) 먼저: 실패하는 테스트로 "원하는 행동"을 박는다 (아직 구현 없음 → red)
async def test_lists_referenced_models(db_session):
    await create_strategy(db_session, blocks=[ai_block("m-42"), ai_block("m-7")])
    result = await repo.list_strategies(owner_id)
    assert result[0].referenced_model_ids == ["m-42", "m-7"]

# 2) 그 다음: AI에게 "이 테스트를 통과시켜"라고 구현을 맡긴다 (green)
테스트는 "구현"이 아니라 "행동"을 검증한다

위 테스트는 결과(referenced_model_ids == [...])만 박는다. 내부를 어떻게 구현했는지는 묻지 않는다. 그래서 AI가 구현을 바꿔도 행동만 같으면 통과한다 — 좋은 테스트의 조건이다. TDD는 구현이 없는 상태에서 테스트를 쓰게 강제하므로, 자연히 이 "행동 중심"을 따르게 만든다. (AI에게 테스트까지 맡기려면 최소한 "실패하는 테스트부터 보여주고, 그 다음 통과 코드"라고 순서를 강제하라. 한 번에 둘 다 만들게 두면 또 순환에 빠진다.)

6.해법 2 — 경계는 진짜로 테스트하라

TDD는 순환은 끊지만, 경계 문제는 따로다. 테스트를 먼저 쓰더라도 거기서 DB를 가짜로 갈아끼우면 §3·§4의 구멍이 똑같이 생긴다. 그래서 두 번째 원칙 — DB·큐·외부 서비스 같은 경계를 건드리는 코드는 가짜로 테스트하지 않는다. (앞 절의 테스트가 가짜가 아니라 진짜 db_session을 쓴 게 그래서다.)

테스트 피라미드로 말하면 이렇게 갈린다.

대상의존성
단위 테스트순수 로직(입력→출력 계산)가짜(mock) OK — 빠르고 많이
통합 테스트DB·외부 서비스가 끼는 경계진짜를 쓴다 — 적지만 반드시

기준은 간단하다 — 내가 소유한·이미 이해한 안쪽은 mock해도 되지만, 내가 소유하지 않은 경계(DB가 실제로 어떻게 응답하나, 그 값이 정말 null이 아닌가)는 진짜로 태운다. AI에게 테스트를 시킬 때도 "경계는 fake 말고 실제 DB로"라고 못박는 게 좋다.

7.해법 3 — "통과"를 "완료"로 착각하지 마라

마지막은 "완료"의 정의를 바꾸는 것이다. lint 통과, 타입 통과, 테스트 초록불 — 이건 "맞다"가 아니라 "가정끼리 서로 모순이 없다"는 뜻일 뿐이다. 가정 자체가 틀렸으면 모두 사이좋게 통과한다.

신호실제로 보장하는 것보장하지 않는 것
lint / 타입 통과문법적으로 말이 됨의도대로 동작함
(가짜) 테스트 초록불가정과 코드가 일치함가정이 현실과 맞음
실제 파이프라인 1회 실행현실에서 한 번은 돌아감(드디어 의미 있는 신호)

그래서 AI가 "수정 완료"라고 하면 결론이 아니라 가설로 받는 게 맞다. 기본 입력으로 실제 파이프라인을 처음부터 끝까지 한 번 돌려보기 전까진, 아무것도 끝난 게 아니다.

8.정리

AI는 코드를 생성할 때도, "테스트 다 통과했다"고 보고할 때도 똑같이 자신감에 차 있다. 원래 테스트의 일은 그 자신감을 의심하는 것인데, AI가 짠 테스트는 코드와 같은 가정 위에서 쓰여 그 자신감을 공유한다 — 의심해야 할 쪽과 의심받아야 할 쪽이 한편이 되어버린다.

그래서 AI로 코딩한다면 순서대로 세 가지다. 테스트를 먼저 써서 순환을 끊고(TDD), 경계는 진짜로 테스트하고, 완료는 직접 돌려본다. 초록불은 "안심하라"는 신호가 아니라, "이제 진짜로 확인해 볼 차례"라는 신호다.

바이브코딩 · AI와 더 잘 일하는 법

반응형

댓글