
테스트 생성을 위한 AI: 도움이 되는 부분과 기만하는 부분
요약
AI를 활용한 테스트 생성의 효용성과 위험성을 분석합니다. AI는 반복적인 스캐폴딩과 경계 사례 생성에는 탁월하지만, 구현 세부 사항에만 의존하거나 검증 대상을 잘못 모킹하여 실제 버그를 놓치는 '테스트 연극'을 만들 위험이 있습니다.
핵심 포인트
- AI는 반복적인 테스트 스캐폴딩 및 설정 코드 작성에 매우 효율적임
- 기존 테스트 패턴을 기반으로 한 경계 사례(edge cases) 확장에 유용함
- 구현 중심의 테스트나 잘못된 모킹으로 인해 실제 버그를 놓칠 위험 존재
- 커버리지 수치가 테스트의 정확성을 보장하지 않음을 인지해야 함
AI는 테스트를 빠르게 작성하는 데 탁월하지만, 실제처럼 보이지만 잘못된 것을 검증하는 테스트를 작성하는 데도 능숙합니다. 여기 유닛 테스트 (unit tests), 엣지 케이스 (edge cases), 그리고 취약한 모킹 (brittle mocks)을 통해 유용한 스캐폴딩 (scaffolding)과 확신에 찬 목소리를 내는 테스트 연극 (test theater) 사이의 경계를 설명합니다.
여러분도 이런 경험이 있을 것입니다. 함수 작성을 마치고, 그것을 AI에 붙여넣은 뒤 테스트를 요청합니다. 30초 후면 12개의 테스트가 생겨납니다. 테스트는 실행됩니다. 통과합니다. 커버리지 (coverage) 배지는 1% 정도 올라갑니다. 여러분은 무언가 유용한 일을 했다고 느끼며, 실제로 대부분의 날에는 그랬습니다.
그러다 운영 환경 (prod)에서 버그가 발생하고, 그 12개의 테스트를 다시 훑어보다가 그중 어떤 것도 버그를 잡아내지 못했을 것이라는 사실을 깨닫게 됩니다. 어떤 것들은 잡아낼 수조차 없었습니다. 몇몇은 동작 (behavior)이 아닌 구현 (implementation)을 테스트하고 있었고, 구현이 계약 (contract)을 깨뜨리는 방식으로 변경되었음에도 테스트는 통과 상태를 유지했습니다. 그중 하나는 검증해야 할 바로 그 대상을 모킹 (mocking)하고 있었습니다. 커버리지 배지는 진실을 말하고 있었습니다. 커버리지에 대해서는 말이죠. 정확성 (correctness)에 대해서는 아니었습니다.
이 글은 마케팅 슬라이드에 어울리지 않는, 테스트를 위한 AI 이야기의 일부입니다. AI는 테스트를 생성하는 데 진정으로 유용합니다. 또한 테스트처럼 보이지만 실제로 여러분이 검증하고자 했던 것을 검증하지 않는 테스트를 만들어내는 데도 진정으로 능숙합니다. 핵심은 여러분이 방금 어떤 종류의 테스트를 받았는지 아는 것입니다.
AI가 실제로 도움이 되는 부분
너무 냉소적으로 보지는 맙시다. 테스트를 작성하는 데 AI를 사용하는 것은 실질적이고 지속적인 생산성 향상을 가져옵니다. 단지 마케팅에서 제안하는 것보다 범위가 좁을 뿐입니다.
AI는 명확한 예시로부터 추론 (extrapolating)하는 데 매우 뛰어납니다. 만약 여러분이 이미 계약을 포착하는 좋은 테스트를 하나 작성했다면, AI에게 동일한 축을 따라 10개를 더 생성해달라고 요청하는 것은 거의 매번 작동합니다. AI는 여러분의 단언 스타일 (assertion style), 팩토리 함수 (factory functions), 명명 규칙 (naming convention)을 파악하여 몇 초 만에 그럴듯한 변형들을 대량으로 만들어냅니다. 이것은 작은 일이 아닙니다. 열 번째 경계 사례 (boundary case)는 숙련된 엔지니어가 직접 작성하기를 꺼려하는 바로 그런 종류의 작업이며, AI는 이를 아주 쉽게 처리합니다.
또한 AI는 주로 타이핑이 필요한 테스트 파일의 부분들, 즉 설정 블록 (setup blocks), 정리 블록 (teardown), 팩토리 헬퍼 (factory helpers), 매개변수화된 입력 테이블 (parameterized input tables), 이미 정의된 형태를 위한 모의 객체 빌더 (mock builders) 등을 처리하는 데 능숙합니다. 시니어 엔지니어가 const user = { id: 'u_1', email: 'a@b.com', ... }를 마흔 번째 타이핑하는 데 쓰는 시간당 비용을 아끼는 것은 소프트웨어 개발에서 가장 쉬운 비용 절감 중 하나이며, AI는 이 비용을 제로로 만듭니다.
그리고 AI는 새로운 코드를 위한 합리적인 스캐폴더 (scaffolder) 역할을 합니다. 함수를 작성한 후 시작점(올바른 임포트 (imports), 올바른 describe 블록, 단언문 (assertions)이 TODO로 남겨진 3~4개의 스켈레톤 테스트가 포함된 파일)이 필요하다면, AI를 사용하는 것이 빈 파일에서 시작하는 것보다 편집기로 더 빠르게 진입하게 해줍니다.
이러한 사례들의 공통점을 주목하십시오. 모든 경우에서, 무엇이 "정확한지"를 결정하는 구조를 제공하는 것은 여전히 _당신_입니다. AI는 당신이 작성한 계약 (contract)의 본문을 채우고 있는 것입니다. 그것이 생산적인 모드입니다. 테스트 생성은 당신이 그 관계를 뒤집는 순간, 즉 AI에게 계약을 단순히 타이핑하는 것이 아니라 계약이 무엇인지 _결정_하라고 요구하는 순간 궤도를 벗어납니다.
첫 번째 거짓말: 동작이 아닌 구현을 검증하는 테스트
AI에게 함수를 건네주고 단위 테스트 (unit tests)를 요청해 보십시오. 그것이 무엇을 하는지 지켜보십시오.
AI는 함수의 본문을 읽습니다. 분기 (branches)를 파악합니다. 그런 다음 분기당 하나의 테스트를 작성합니다. 결과는 완벽해 보입니다. 모든 if, 모든 else, 모든 조기 반환 (early return)에 대응하는 it() 블록이 있습니다. 커버리지 (coverage)는 100%에 도달합니다. 모두가 만족하며 퇴근합니다.
문제는 AI가 당신의 함수가 해야 하는 일을 테스트하지 않았다는 점입니다. AI는 당신의 함수가 현재 수행하는 일을 테스트했습니다. 이 둘은 서로 다른 산물이며, 그 차이가 바로 버그가 숨어 있는 곳입니다.
할인 함수를 예로 들어보겠습니다. 당신은 다음과 같은 코드를 배포합니다:
src/pricing/discount.ts
export function applyDiscount(amount: number, code: string): number {
if (code === 'SUMMER25') return amount * 0.75;
if (code === 'FRIEND10') return amount * 0.9;
...
당신은 AI에게 단위 테스트를 요청합니다. AI는 다음과 같은 결과를 줍니다:
src/pricing/discount.test.ts
describe('applyDiscount', () => {
it('SUMMER25를 25% 할인으로 적용한다', () => {
expect(applyDiscount(100, 'SUMMER25')).toBe(75);
...
이 테스트들은 통과합니다. 함수는 "테스트"되었습니다. 그리고 만약 당신이 실수로 SUMMER25 승수를 0.75에서 0.6으로 변경한다면, 정확히 그중 하나만 실패하며, 코드가 코드가 하는 대로 작동하고 있다는 사실은 알려주지만 코드가 _비즈니스(business)_가 원하는 대로 작동하고 있다는 사실은 결코 알려주지 않습니다.
이제 당신이 실제로 검증해야 했던 계약(contract), 즉 동료가 사양(spec)을 정의해달라고 요청했을 때 작성했을 내용을 살펴보십시오:
- 고객에게
SUMMER25가 활성 코드일 때 25% 할인이 적용된다. FRIEND10이 활성 코드일 때 10% 할인이 적용되지만, 이는 첫 구매자에게만 해당된다.- 알 수 없거나 만료된 코드는 원래 금액을 반환하고
code_unrecognized이벤트를 발생시킨다. - 할인은 동일한 장바구니에 두 번 적용되지 않는다.
- 100% 할인 코드가 어떻게든 활성화되더라도, 반환된 금액은 결코 0보다 작을 수 없다.
이 중 어느 것도 AI의 테스트 파일에는 들어있지 않습니다. 들어있을 수도 없습니다. AI는 그것들을 본 적이 없기 때문입니다. AI는 함수 본문(function body)을 보고 코드의 형태로부터 계약을 추론했는데, 이는 정확히 잘못된 방향입니다. 계약이 코드를 생성해야 하는 것이지, 그 반대가 되어서는 안 됩니다.
이것이 바로 "AI가 방금 작성한 함수를 위해 AI가 테스트를 작성한다"는 말이 왜 그토록 유혹적이면서도 무용지물인지를 설명하는 미묘한 이유입니다. AI는 어떤 암묵적인 가정(예를 들어, 할인은 항상 양수라는 가정) 하에 함수를 작성하고, 동일한 암묵적 가정 하에 테스트를 작성합니다. 숨겨진 전제(premise)가 두 산출물 모두에 자리 잡고 있어 서로 일치하게 됩니다. 프로덕션(Production) 환경은 그 전제가 처음으로 도전을 받는 곳이며, 그 시점에서 테스트 스위트(test suite)는 _버그(bug)_의 편에 서게 됩니다.
해결책은 단위 테스트 (unit tests)를 위해 AI를 사용하는 것을 중단하는 것이 아닙니다. AI가 무엇을 검증할지 결정하도록 내버려 두는 것을 중단하는 것입니다. 먼저 계약 (contract)을 작성하세요. 테스트당 한 문장씩, 평이한 언어로 작성합니다: "알 수 없는 코드는 원래 금액을 반환한다", "만료된 코드는 올바르게 입력하더라도 절대 적용되지 않는다". 그런 다음 이 문장들을 AI에게 전달하고 AI가 어설션 (assertions)을 채우도록 하세요. AI는 테스트의 본문 (body)을 생성할 수는 있지만, 테스트의 의도 (intent)를 생성할 수는 없습니다.
두 번째 거짓말: 엣지 케이스 (Edge Cases)처럼 들리는 엣지 케이스
AI에게 엣지 케이스 (edge cases)를 물어보면 매우 자신 있게 생성해낼 것입니다. AI가 거의 모든 함수에 대해 확실하게 생성해낼 목록은 다음과 같습니다:
- 빈 문자열 (Empty string)
null및undefined- 빈 배열 (Empty array)
- 길이가 1인 배열
- 0, -1, 정수 최댓값 (integer max)
- 매우 긴 문자열
- 공백만 있는 입력
- "까다로울 수 있는" 유니코드 (Unicode)
이들 각각은 모두 실제 엣지 케이스입니다. 하지만 이들은 모두 뻔한 엣지 케이스이며,
- 할인 코드는 유효하지만, 고객이 이미 두 번 사용한 경우.
- 아이템 배열에 동일한 제품에 대한 참조가 두 개 포함되어 있고 각각 별도로 주문되었는데, 중복 제거 (dedupe) 로직이 주문 ID가 고유하다고 가정하고 작성된 경우.
- 사용자 이름의 정렬 (collation) 방식이 MySQL에서는 대소문자를 구분하지 않지만, 애플리케이션 계층에서는 대소문자를 구분하는 경우.
- UTC 자정에 실행되도록 예약된 작업이 로컬 시간으로 설정된 서버에서 실행되는데, 하필 서머타임 (DST)이 바뀌는 밤이라 작업이 두 번 실행되거나 아예 실행되지 않는 경우.
- 재시도 (retries)를 위한 멱등성 키 (idempotency key)가 요청 본문 (request body)을 해싱하여 생성되는데, 본문에 재시도 사이에 값이 변하는 타임스탬프 필드가 포함된 경우.
- 사용자가 두 개의 대기 중인 비밀번호 재설정 요청을 가지고 있으며, 새로운 요청이 소비되기 전까지 이전 요청도 여전히 유효하여 소비 순서가 중요한 경우.
- 행에 "소프트 삭제 (soft-deleted)" 플래그가 설정되어 있지만, 관련 외래 키 (foreign key)가 여전히 해당 행을 가리키고 있어 조인 (join) 시 어제 보고서에서 주문 데이터가 조용히 누락되는 경우.
이 중 그 어떤 것도 함수 본문을 읽는 것만으로는 찾아낼 수 없습니다. 이 모든 것들은 시스템을 알고 있어야만 가능합니다. AI에게 이 내용들을 알려줄 수는 있지만 (도메인 규칙을 입력하면 깔끔하게 테스트를 생성할 것입니다), AI는 스스로 이를 절대로 생성할 수 없습니다. 왜냐하면 할 수 없기 때문입니다. AI는 당신의 시간대 버그 이력, 정렬 (collation)의 특이점, 재시도 의미론 (retry semantics), 또는 결정 당시 회의실에 있던 사람들이 모두 떠나버려 아무도 문서화하지 않은 암묵적인 불변량 (invariant)을 알지 못합니다.
이것이 일상적인 실무에 주는 의미는 간단합니다. AI에게 "이 함수에 대한 엣지 케이스를 알려줘"라고 요청하면, 당신은 뻔한 케이스들을 받게 될 것입니다. 그것들을 가져다 쓰세요, 공짜니까요. 하지만 그 후 1분 동안 앉아서, 당신이 테스트하려는 함수에 대한 실제 도메인 엣지 케이스를 직접 손으로 하나만 적어보세요. 딱 하나만요. "이 함수가 처리해야 하지만 내가 새벽 3시에 걱정할 만한 것들"이라는 솔직한 목록은 짧겠지만, 바로 그 목록이 당신을 잠에서 깨우는 버그를 잡아내는 목록이 될 것입니다.
이것을 위해 만들 수 있는 습관이 있습니다. 함수를 완료할 때마다, '내가 걱정하는 것들(things I'm scared of)'이라는 한 줄짜리 주석을 어딘가에 적어두세요. 노트북이나 초안 PR 설명, 또는 // TODO: tests 라인에 좋습니다. 필터링하지 마세요. 그런 다음 이 라인들을 AI에게 테스트 프롬프트로 제공하세요. 당신은 AI에게 엣지 케이스(edge cases)를 생각해내라고 요청하는 것이 아니라, 이미 당신이 생각한 엣지 케이스에 대한 어설션(assertions)을 작성해달라고 요청하는 것입니다. 모델은 후자 작업을 훨씬 더 잘 수행합니다.
세 번째 거짓말: 테스트 자체를 속이는 Mock (가짜 객체)
이것이 세 가지 중 가장 나쁜 부분인데, 그 이유는 가장 미묘하고 가장 사랑받기 때문입니다. AI는 무언가를 Mock(가짜 객체화)하는 것을 좋아합니다. 부수 효과(side effect)가 있는 모든 것(데이터베이스 호출, HTTP 요청, 시계, 난수 생성기(random source), 파일 시스템, 메시지 버스 등)은 기본적으로 Mock 처리됩니다. 그 이유가 말이 됩니다: 테스트는 결정론적(deterministic)이어야 하고, 의존성(dependencies)은 격리되어야 하며, Mocking은 이 두 가지를 모두 수행하는 표준적인 방법입니다. AI는 잘 문서화된 패턴을 따르고 있는 것입니다.
이 패턴은 두 가지 특정 방식으로 실패하며, 둘 다 충분히 자주 발생하여 예측할 수 있습니다.
실패 1: Mock은 틀렸지만 테스트는 어쨌든 통과한다. 외부 API를 호출하는 함수를 테스트하고 있다고 가정해 봅시다. AI가 해당 API를 Mock 처리하고 더미 값(fixture)을 반환합니다. 이 더미 값은 당신의 특정 제공업체가 실제로 반환하는 값이 아니라, AI가 API가 응답할 것이라고 생각하는 모양으로 만들어집니다. 당신의 함수는 response.data.success를 읽지만, 실제 API는 response.body.ok를 반환합니다. 테스트는 Mock이 실제 호출이 아닌 당신의 함수의 가정으로부터 구축되었기 때문에 이 차이를 결코 알아차리지 못합니다.
src/payments/charge.test.ts
jest.mock('../lib/stripe', () => ({
charge: jest.fn().mockResolvedValue({
data: { success: true, id: 'ch_123' } }
...```
이 테스트는 통과합니다. 그리고 영원히 통과할 것입니다. 또한 이 테스트는 테스트 파일 내부에서는 감지할 수 없는 방식으로 잘못되어 있습니다. 왜냐하면 이 테스트가 검증하는 유일한 사항은 함수가 AI가 꿈꿔낸 가상의 형태 (fictional shape)로부터 데이터를 읽는다는 것뿐이기 때문입니다. 실제 프로바이더 (provider)는 수많은 엣지 케이스 (edge cases)에서 `{ success: false, error: 'card_declined' }`를 반환할 수 있으며, 이 테스트는 귀하의 코드가 이를 처리하는지 여부에 대해 아무런 정보도 제공하지 않을 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기