AI가 짠 테스트는 다 통과한다 — 그게 함정이다
- AI에게 "기능 + 테스트"를 시키면 테스트는 거의 항상 초록불로 돌아온다. 그런데 그 초록불은 생각보다 의미가 없다.
- AI는 "버그를 잡는 테스트"가 아니라 "통과하는 테스트"를 짜는 경향이 있다 — 코드와 테스트를 한 번에 만들면서, 테스트가 코드를 사후에 흉내 내기 때문이다(순환). 게다가 진짜 의존성(DB·외부 서비스)을 가짜(mock)로 갈아끼운다.
- 그래서 버그는 거의 항상 그 가짜로 치워버린 경계에 산다 — 코드와 실제 시스템이 만나는 자리.
- 대응 세 가지: ① 테스트를 먼저 쓴다(TDD) — 순환을 끊는다. ② 경계는 진짜로 테스트한다(통합 테스트, 가짜 금지). ③ "완료"는 초록불이 아니라 실제 파이프라인을 한 번 돌려본 것으로 정의한다.
1.초록불이 알려주지 않는 것
AI에게 기능을 맡기며 "테스트도 같이 짜줘"라고 하면, 결과는 거의 항상 초록불이다. 사람은 초록불을 보면 안심한다. 문제는, AI가 짠 테스트의 초록불은 사람이 짠 테스트의 초록불과 의미가 다르다는 것이다.
사람은 보통 "이게 깨지면 안 되는데" 하는 지점을 찌르려고 테스트를 짠다. AI는 다르다. AI가 최적화하는 건 초록불 그 자체다 — 통과시키는 게 목표지, 버그를 드러내는 게 목표가 아니다. 그래서 안심하고 넘어간 자리에서 종종 운영 장애가 난다. 왜 그런지, 그게 왜 위험한지, 어떻게 막는지 순서대로 보자.
2.AI는 "통과하는 테스트"를 짠다
AI에게 코드와 테스트를 한 번에 맡기면, 테스트는 방금 만든 코드를 사후에 정당화하는 방향으로 쓰인다. 거기에 초록불로 가는 가장 빠른 길 — 손이 많이 가는 진짜 의존성을 가짜로 갈아끼우기 — 이 더해지면, 테스트는 이런 모양이 된다.
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이었다.
둘 다 화면을 통째로 죽이는 버그였는데, 테스트 네 개는 전부 새파랬다.
# 가짜엔 있던 필드가 진짜 객체엔 없다(다른 테이블에 있음) → 접근하는 순간 깨짐 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가 자기 코드에 맞춰 테스트를 깎는 순환이 원천 차단된다.
# 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' 카테고리의 다른 글
| AI에게 규칙을 한 번만 가르치는 법 (0) | 2026.06.13 |
|---|---|
| AI는 7곳 중 1곳만 고치고 "완료"라고 한다 (0) | 2026.06.13 |
| 어제 고친 버그를 오늘 또 만드는 AI — 컨텍스트 엔지니어링 (0) | 2026.06.13 |
| AI는 일단 막으려 한다 — 권한은 주고, 압력은 가격으로 (0) | 2026.06.13 |
댓글