AI로 '모킹 지옥'을 피하며 견고한 단위 테스트를 작성하는 법
방금 당신은 5개의 외부 API와 연동되고 복잡한 예외 처리 로직이 포함된 결제 처리(Payment) 서비스를 완성했습니다. 퇴근 시간을 앞당기기 위해 코드를 전체 드래그한 뒤 AI 에디터에 이렇게 입력합니다. "이 코드에 대한 단위 테스트를 전부 짜줘."
AI는 불과 10초 만에 300줄짜리 완벽해 보이는 Jest 코드를 뱉어냅니다. 두근거리는 마음으로 `npm run test`를 돌려보니 터미널 화면이 초록색 체크 표시(Pass)로 가득 찹니다. 테스트 커버리지도 95%를 넘겼습니다. 아주 만족스럽게 퇴근합니다.
2주 뒤, 당신은 DB 쿼리를 던지는 내부 private 메서드의 이름을 살짝 바꾸거나 데이터베이스 구조를 약간 정리했습니다. 비즈니스 로직의 '결과'는 전혀 바뀌지 않았는데, 갑자기 40개의 테스트가 와르르 깨집니다. 당황해서 AI가 짰던 테스트 코드를 열어보고 끔찍한 사실을 깨닫습니다. AI가 외부 의존성뿐만 아니라 '내부 함수 호출'까지 모조리 모킹(Mocking)해버린 것입니다. 이 테스트는 결제가 '성공했는지'를 검증한 게 아니라, 그저 '특정 내부 함수가 특정 파라미터로 호출되었는지'만 앵무새처럼 감시하고 있었습니다.
1. '모킹 지옥(Mocking Hell)'이란 무엇인가? (Deep Dive)
이것이 바로 전형적인 '모킹 지옥(Mocking Hell)'입니다. 보일러플레이트 코드를 AI로 생성할 때 엄격한 아키텍처 템플릿이 필요한 것처럼, 테스트를 AI에게 맡길 때도 엄격한 행동(Behavior) 경계를 설정해 주지 않으면 재앙이 발생합니다.
구현(Implementation) 테스트의 함정
우리가 단위 테스트를 짜는 진짜 목적은 '코드를 리팩토링할 때 기존 기능이 망가지지 않았다는 확신'을 얻기 위함입니다. 그런데 완성된 코드를 AI에게 통째로 던져주고 "테스트해 줘"라고 하면, AI는 내부 비즈니스 맥락을 이해하지 못한 채 오직 '코드 커버리지 100%'를 달성하기 위해 코드를 한 줄씩 읽으며 모든 외부 요인을 가짜(Mock)로 덮어버립니다.
당신은 테스트를 짠 게 아닙니다
AI가 짜준 코드는 테스트라기보다 '코드의 현재 상태를 강제로 얼려버린 동결 스크립트'에 가깝습니다. 코드가 '무엇을(What)' 해야 하는지가 아니라, '어떻게(How)' 짜여 있는지를 테스트하기 때문에 변수명 하나만 바꿔도 테스트가 깨져버리는 쓸모없는 쓰레기 코드가 탄생합니다.
2. '행동' 테스트와 '구현' 테스트의 명확한 차이
이 문제를 해결하려면 AI에게 족쇄를 채워야 합니다. 내부적인 동작 과정이 아니라, 겉으로 드러나는 '결과(Outcome)'만 검증하도록 강제해야 합니다.
| 테스트 방식 | AI가 짠 코드의 특징 | 리팩토링 시 결과 |
|---|---|---|
| 구현 기반 (나쁨) | expect(mockDb.save).toHaveBeenCalledWith({ status: 'PAID' }) |
DB 저장 로직이나 라이브러리를 ORM으로 바꾸는 순간 즉시 100개의 테스트가 다 깨짐. |
| 행동 기반 (좋음) | const order = await service.getOrder(id); expect(order.status).toBe('PAID') |
평화롭게 통과함. 내부 DB 로직이 어떻게 바뀌든 '최종 상태'는 올바르기 때문. |
3. 단계별 행동 기반 테스트(Behavior-Driven) 작성 가이드
그렇다면 AI가 모킹을 남발하지 않고 튼튼한 행동 기반 테스트를 작성하게 하려면 어떻게 해야 할까요?
1단계: 내부 구현 코드 숨기기 (블랙박스 접근법)
가장 중요한 원칙은 프롬프트에 원본 코드를 전부 넣지 않는 것입니다. `PaymentService.ts`의 수백 줄짜리 코드를 다 복사해서 붙여넣으면 AI는 필연적으로 내부 로직을 흉내 냅니다. 대신 퍼블릭 인터페이스(Interface)와 비즈니스 요구사항만 던져주어야 합니다. AI가 내부를 볼 수 없게 '블랙박스'로 만들어야 합니다.
2단계: 프롬프트로 제약 걸기
AI에게 테스트 작성을 지시할 때는 다음 프롬프트 구조를 활용하세요.
// 최악의 프롬프트:
"이 파일의 단위 테스트를 짜줘: [의존성이 5개나 주입된 500줄짜리 복잡한 로직 전체 복붙]"
// 훌륭한 실전 프롬프트:
"PaymentService에 대한 단위 테스트를 작성해 줘.
이 서비스는 단 하나의 공개 메서드만 가짐: `processPayment(orderId: string, amount: number)`.
규칙 1: 내부 데이터베이스 레이어를 절대 Mocking 하지 마. 우리가 세팅해둔 인메모리 SQLite를 사용할 것.
규칙 2: 내부 private 메서드 호출 여부를 검증하지 마.
규칙 3: 오직 다음 3가지 비즈니스 결과만 단위 테스트로 작성해:
- 결제 금액이 음수이면 Error를 던져야 함.
- 성공 시 주문의 상태가 PAID로 업데이트되어야 함.
- 영수증 트랜잭션 ID 문자열을 정상적으로 반환해야 함."
3단계: 가짜 객체(Mock) 대신 '진짜 가짜(Fake)' 쥐여주기
AI는 여러분의 도메인 데이터 구조를 모르기 때문에 `jest.fn()`이나 `verify()`를 남발하여 코드를 지저분하게 만듭니다. 이를 방지하려면 AI에게 Mock 대신 페이크(Fake) 객체를 쓰라고 명시적으로 지시해야 합니다. (Fake는 흉내만 내는 Mock과 달리, 실제로 동작하는 가벼운 인메모리 구현체입니다.)
4. 예외 상황 및 트러블슈팅 (Edge Cases)
Q. 외부 결제 API (예: Stripe)는 무조건 모킹해야 하지 않나요?
맞습니다. 외부 네트워크를 타는 API는 단위 테스트에서 모킹하는 것이 정석입니다. 하지만 이 경우에도 AI가 `axios.post` 자체를 모킹하게 두어서는 안 됩니다. 외부 API와 통신하는 얇은 인터페이스(예: `PaymentGateway`)를 만들고, 테스트 환경에서는 그 인터페이스를 구현한 `FakePaymentGateway`를 주입하세요. 프롬프트에는 "네트워크 호출을 모킹하지 말고 준비된 FakePaymentGateway를 주입해서 성공/실패 케이스를 테스트하라"고 명시해야 합니다.
Action Step: 프롬프트 컨텍스트에 Fake 클래스 포함하기
AI에게 테스트 작성을 시키기 전, 여러분이 미리 만들어둔 `FakeEmailSender`나 `InMemoryUserRepository` 클래스를 프롬프트 창에 함께 붙여넣으세요. 그리고 "Jest의 Mock 기능을 쓰지 말고, 의존성 주입 시 반드시 이 Fake 클래스들을 사용해서 테스트를 짜라"고 지시하세요. AI가 훨씬 깔끔하고 유지보수하기 쉬운 설정(Setup) 코드를 만들어냅니다.
마치며: 내 코드를 지키는 진짜 방패
이제부터는 AI가 여러분이 짠 코드의 내부 로직을 앵무새처럼 따라 읊는 테스트를 짜게 내버려두지 마세요. 공개 인터페이스를 제한하고, 내부를 가리고, Fake 객체 사용을 강제하는 순간 AI가 짠 테스트는 수정할 때마다 깨지는 골칫덩어리에서 내 코드를 든든하게 지켜주는 진짜 방패로 거듭날 것입니다.