AI(ChatGPT·Claude 같은 대형 언어 모델, LLM)에게 테스트를 짜달라고 하면 될까요? 됩니다. 다만 그대로 믿으면 안 돼요. AI는 그럴듯한 테스트를 몇 초 만에 뽑아주지만, 정작 중요한 경계·실패·접근성 케이스를 조용히 빼먹는 경향이 있거든요. 이 글은 AI를 테스트 작성의 조수로 제대로 부리는 법입니다 — 좋은 초안을 받는 요령부터, 받은 테스트가 진짜 일하는지 검증하는 체크리스트까지.
시리즈로 따라오셨다면 마지막 챕터의 시작이에요. 지금까지 쌓은 검증 기술이 이 장에서 ‘AI를 다루는 힘’으로 바뀝니다. 검색으로 처음 오셨어도 예제는 자급자족합니다.
실습 코드: frontend-testing-lab — 이번 편의
filterByQuery와debounce는 앞선 편에서 이미 들어와 있어,step-19는step-18과 같은 상태입니다. AI 초안과 뮤테이션 실험은 아래 코드 블록만으로 재현됩니다.
이번 편의 목표는 세 가지입니다.
- AI로 테스트 초안 빠르게 뽑기
- AI가 놓치는 케이스(경계·접근성)
- AI 테스트를 검증하는 체크리스트

AI는 좋은 초안러#
단위 테스트 편의 filterByQuery를 주고 “테스트 짜줘"라고 하면, AI는 몇 초 만에 그럴듯한 스위트를 뽑아냅니다. 대개 이런 모양이에요.
// AI가 뽑은 초안 (전형적)
it('filters by name', () => {
expect(filterByQuery(users, 'Alice')).toHaveLength(1)
})
it('returns all when query is empty', () => {
expect(filterByQuery(users, '')).toHaveLength(2)
})나쁘지 않죠. 그런데 벌써 하나 이상합니다 — 데모 앱의 목 데이터에는 사용자가 3명인데 AI는 2명이라 가정했어요. 우리 데이터를 보여준 적이 없으니 지어낸 겁니다. 숫자가 우연히 맞으면 다행이지만 어긋나면, 멀쩡한 코드에서 테스트가 빨간불이 됩니다. 반복적인 조합을 빠르게 깔아주는 데는 정말 유용하지만, 사실 관계는 처음부터 사람이 쥐고 있어야 한다는 예고편인 셈이죠.
초안의 질은 무엇을 주느냐에 크게 좌우됩니다. 대부분은 이렇게 던지고 말죠.
이 함수 테스트 짜줘
export function filterByQuery(users, query) { ... }받는 쪽 입장에서 생각해보면 당연한 결과입니다. 요구사항도 모르고, 데이터가 몇 건인지도 모르고, 이 프로젝트가 어떤 스타일로 테스트를 쓰는지도 모르니까요. 모르는 건 지어냅니다. 아까 3명을 2명으로 만든 게 그거예요.
세 가지를 더 얹으면 초안이 달라집니다.
아래 함수의 테스트를 Vitest로 작성해줘.
[요구사항]
- 이름과 이메일 둘 다로 검색된다
- 대소문자를 구분하지 않는다
- 검색어가 비어 있거나 공백뿐이면 전체를 반환한다
[경계 조건도 반드시 포함]
빈 문자열 / 공백만 있는 문자열 / 결과 0건
[이 프로젝트의 테스트 스타일 — 이 문체를 따라줘]
it('빈 검색어는 전체를 반환한다', () => {
const result = filterByQuery(users, ' ')
expect(result).toHaveLength(2)
})
[대상 코드]
export function filterByQuery(users, query) { ... }세 블록이 각각 다른 일을 합니다. 요구사항은 지어내기를 막고, 경계 조건은 행복 경로만 훑는 습성을 눌러주고, 스타일 견본은 팀 코드에 그대로 붙일 수 있는 모양으로 뽑아줍니다.
여기에 하나 더 — 데이터를 실제로 보여주세요. 픽스처를 그대로 붙여넣으면 “users는 몇 명인가” 같은 걸 상상하지 않습니다. 프롬프트가 길어지는 게 아까워 보이지만, 지어낸 숫자 하나 고치는 시간보다 짧습니다.
AI가 잘 놓치는 것#
그런데 위 초안엔 구멍이 있어요. 우리가 단위 테스트 편에서 직접 잡았던 케이스들이 없죠.
- 공백만 있는 검색어(
' ') —trim()검증 - 이메일로도 검색되는지 (요구사항의 절반!)
- 대소문자 혼합 (
'ALICE')
AI는 ‘행복 경로’에 강하고 경계·실패·접근성에 약합니다. 시키지 않으면 role="alert" 검증이나 접근 가능한 이름 같은 건 거의 안 나와요.
왜 그런지 짐작해보면 이유가 단순합니다. AI는 세상에 널려 있는 코드를 학습했는데, 그 코드들이 원래 경계를 잘 안 챙기거든요. 예제 코드나 튜토리얼은 대개 잘 되는 경우만 보여주고, 접근성 단언이 들어간 테스트는 그중에서도 소수입니다. 평균을 학습한 결과가 평균적인 테스트로 나오는 셈이죠. 그러니 “알아서 잘 해주겠지"가 아니라 빠뜨릴 것을 미리 지목해주는 쪽이 맞습니다.
그래서 어떻게 찾아내나#
“경계를 챙겨라"는 말만으로는 손이 안 움직입니다. 저는 세 가지를 순서대로 합니다.
하나, 요구사항을 문장으로 쪼개 하나씩 지웁니다. 아까 프롬프트에 적었던 요구사항이 세 줄이었죠 — 이름과 이메일 둘 다 검색, 대소문자 무시, 빈 검색어는 전체 반환. 이걸 옆에 펼쳐두고 초안의 테스트와 짝을 지어보면 이메일 검색에 짝이 없다는 게 바로 보입니다. AI가 요구사항의 절반을 통째로 빠뜨린 건데, 코드만 들여다봤으면 못 찾았을 거예요. 요구사항이 문서에 없다면 그것부터가 문제고요.
둘, 매번 같은 경계 목록을 훑습니다. 저는 이 여섯 개를 고정으로 씁니다 — 빈 값, 공백만, 결과 0건, 최댓값·초과, 중복, 그리고 대소문자·특수문자. 기억에 의존하면 그날 컨디션에 따라 빠지는데, 목록으로 두면 기계적으로 걸러집니다. 초안에서 빠졌던 ' '와 'ALICE'가 정확히 이 목록의 2번과 6번이에요.
셋, 접근성은 따로 시킵니다. 이건 “포함해줘"라고 적지 않으면 거의 안 나옵니다.
위 테스트에 접근성 단언을 추가해줘.
CSS 클래스나 test-id 대신 role과 접근 가능한 이름으로 요소를 찾고,
에러 메시지는 role="alert"로 읽히는지 확인하는 형태로.한 가지 더 — AI에게 되물어보는 것도 꽤 먹힙니다. “이 테스트가 놓친 경계 케이스를 나열해줘"라고 하면 자기가 방금 안 쓴 것들을 곧잘 짚어내요. 처음부터 시키면 안 하면서 물어보면 아는, 조금 얄미운 구석이 있습니다. 물론 그 목록도 그대로 믿을 건 아니고 위의 두 단계로 거르면 됩니다.
검증 체크리스트#
AI가 짠 테스트에 사인하기 전, 세 가지만 확인하세요.
- 뮤테이션 체크: 코드를 일부러 깨보세요(
trim()제거 등). 테스트가 빨간불이 안 되면 그건 장식품입니다. - 동작 vs 구현: 내부 구현(함수 호출 횟수 등)을 박제하고 있진 않은가?
- 경계·접근성: 빈 값, 에러 상태, role/이름 검증이 들어갔는가?
1번을 아까 그 AI 초안에 실제로 해봤습니다. 픽스처는 단위 테스트 편에서 쓰던 2명짜리 그대로예요 — 초안의 숫자가 마침 맞아떨어지니 결함 말고는 변수가 없죠. 3막짜리 실험입니다.
# 1막 — AI 초안 2개, 멀쩡한 코드에서: 전부 통과
Tests 2 passed (2)
# 2막 — filterByQuery에서 trim()을 일부러 제거: 그래도 전부 통과?!
Tests 2 passed (2)
# 3막 — 경계 테스트(공백만 있는 검색어) 하나 추가, 같은 결함:
× returns all when query is whitespace only
AssertionError: expected [] to have a length of 2 but got +0
Tests 1 failed | 2 passed (3)2막이 핵심입니다. 코드에 진짜 결함을 심었는데 AI 초안은 전부 초록불 — 이 스위트는 trim 결함 앞에서 장식품이라는 게 증명됐어요. 그리고 3막, 경계 테스트 하나가 들어가자마자 같은 결함이 곧장 빨간불로 잡힙니다. “테스트가 있다"와 “테스트가 지킨다"의 차이가 이 세 줄 출력에 다 들어 있어요.
1번이 낯설지 않을 거예요 — 시리즈 내내 편마다 해온 “일부러 부숴봅시다"가 바로 이것입니다. 코드에 결함(뮤테이션)을 심고 테스트가 잡아내는지 보는 기법으로, 정식 명칭은 뮤테이션 테스트예요. 사람이 짠 테스트야 짜면서 의도를 아니까 가끔 해도 되지만, AI가 짠 테스트는 의도를 모른 채 받는 것이라 이 검증이 필수가 됩니다. 커버리지는 “테스트가 코드를 지나갔다"까지만 말해주지만, 뮤테이션은 “테스트가 결함을 실제로 잡는다“를 말해주거든요. (스위트가 커지면 이 과정을 자동으로 돌려주는 Stryker 같은 도구도 있습니다 — Vitest도 지원해요. 수백 개의 뮤테이션을 심고, 테스트가 못 잡아 살아남은 뮤턴트와 전체 중 잡아낸 비율인 뮤테이션 점수를 리포트로 줍니다.)
2번이 왜 어려운가 — 동작과 구현의 경계#
1번은 부수면 되니까 기계적입니다. 2번 “구현을 박제하고 있진 않은가"가 어려워요. 말 자체가 추상적이거든요.
debounce로 보면 금방 잡힙니다. 비동기 편에서 가짜 타이머로 다뤘던 그 함수예요 — 연달아 부르면 마지막 것만 실행되는.
// A. 동작을 검사한다 — "무엇을 보장하는가"
it('마지막 인자로 한 번만 실행된다', () => {
vi.useFakeTimers()
const fn = vi.fn()
const d = debounce(fn, 100)
d('a'); d('b')
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('b')
})
// B. 구현을 박제한다 — "어떻게 만들었는가"
it('clearTimeout을 두 번 부른다', () => {
vi.useFakeTimers()
const spy = vi.spyOn(globalThis, 'clearTimeout')
const d = debounce(vi.fn(), 100)
d('a'); d('b')
expect(spy).toHaveBeenCalledTimes(2)
})둘 다 지금은 통과합니다. 그러니 초록불만 보고는 구분이 안 돼요. 차이는 코드를 고칠 때 드러납니다.
debounce 내부를 타이머 취소 대신 세대 카운터 방식으로 바꿔봤습니다. 나중에 부른 호출이 이기게 하는 건 똑같고, 밖에서 보는 동작도 그대로예요.
// 동작은 같고 만드는 방식만 다르다 — clearTimeout을 쓰지 않는다
let generation = 0
return (...args: Parameters<T>) => {
const mine = ++generation
setTimeout(() => { if (mine === generation) fn(...args) }, ms)
}바꾸고 돌린 결과입니다.
× clearTimeout을 두 번 부른다 1ms
AssertionError: expected "clearTimeout" to be called 2 times, but got 0 times
Tests 1 failed | 1 passed (2)A는 살아남고 B만 깨졌습니다. 사용자에게 달라진 건 아무것도 없는데 테스트가 빨간불이 된 거예요. 이런 테스트는 리팩터링을 막습니다. 고칠 때마다 코드가 아니라 테스트를 고치게 되고, 그러다 보면 “테스트가 있어서 못 고치겠다"는 말이 나옵니다.
구분하는 질문은 하나예요. “이 단언이 깨졌을 때, 사용자가 겪는 문제가 있나?” 없다면 구현을 박제한 겁니다.
AI가 이 함정에 특히 잘 빠집니다. 내부를 들여다보는 테스트가 학습 데이터에도 흔할 테고, 무엇보다 AI는 그 코드의 계약이 뭔지 모르니까 눈에 보이는 걸 그대로 단언하거든요. toHaveBeenCalledTimes가 잔뜩 붙어 있으면 한 번씩 물어보세요 — 이게 약속인가, 아니면 지금 그렇게 짜여 있을 뿐인가.

생성은 위임할 수 있어도 책임은 위임할 수 없어요. AI가 짠 테스트에 사인하는 건 결국 사람입니다. 이건 접근성에서 AI 대체텍스트를 사람이 검토해야 하는 것과 똑같은 원리예요.
“AI야 테스트 짜줘” → 20개 뚝딱. 그런데 코드를 일부러 깨봐도 다 초록불이면? 축하합니다, AI가
expect(true).toBe(true)의 사촌들을 20개 선물한 거예요.
한 장 요약#
- AI는 훌륭한 초안러 — 반복 조합을 몇 초에 깔아주지만, 행복 경로에 강하고 경계·실패·접근성에 약함
- 좋은 초안을 받으려면 코드만 주지 말 것 — 요구사항 + 경계 지시 + 기존 테스트 견본 + 실제 데이터를 함께
- 빠진 걸 찾는 방법도 정해두기 — 요구사항 문장과 1:1 대조 · 고정 경계 목록 훑기 · 접근성은 따로 시키기
- 받은 테스트는 3단 검증: 뮤테이션 체크(일부러 깨서 빨간불 확인) · 구현 박제 여부 · 경계와 접근성 포함 여부
- 구현 박제를 가리는 질문은 하나 — “이 단언이 깨졌을 때 사용자가 겪는 문제가 있나”. 없으면 리팩터링만 막는 테스트
- 코드를 깨도 초록불인 테스트는 장식품 —
expect(true).toBe(true)의 사촌일 확률 - 생성은 위임해도 책임은 위임 불가 — 사인하는 건 사람
초안은 AI, 확정은 사람#
AI를 조수로 쓰는 법을 익혔어요. 다음 편은 브라우저 자동화와 AI의 만남 — Playwright × AI입니다.
레벨업: AI가 짠 테스트를 받아쓰지 않고, 검증해서 쓸 수 있습니다.
다음 편: Playwright × AI — codegen·MCP·자가치유 셀렉터
낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.
