# AI로 테스트 생성·보강 — 검증의 몫은 사람

> 「프론트엔드 테스트 제대로 하기」 시리즈 19편. ChatGPT·Claude 같은 LLM에게 테스트를 맡기면 경계·실패·접근성 케이스를 조용히 빼먹곤 합니다. 좋은 초안을 받는 요령부터 뮤테이션 테스트로 AI가 짠 테스트를 검증하는 체크리스트까지 다룹니다.

**Published:** 2026-08-06 | **Updated:** 2026-08-06

---


> 「프론트엔드 테스트 제대로 하기」 시리즈의 **19편**입니다. [전체 목차 보기](/series/프론트엔드-테스트-제대로-하기/) · [용어집](/posts/frontend-testing-glossary/)

AI(ChatGPT·Claude 같은 대형 언어 모델, LLM)에게 테스트를 짜달라고 하면 될까요? **됩니다. 다만 그대로 믿으면 안 돼요.** AI는 그럴듯한 테스트를 몇 초 만에 뽑아주지만, 정작 중요한 경계·실패·접근성 케이스를 조용히 빼먹는 경향이 있거든요. 이 글은 AI를 테스트 작성의 조수로 제대로 부리는 법입니다 — 좋은 초안을 받는 요령부터, 받은 테스트가 진짜 일하는지 검증하는 체크리스트까지.

시리즈로 따라오셨다면 마지막 챕터의 시작이에요. 지금까지 쌓은 검증 기술이 이 장에서 'AI를 다루는 힘'으로 바뀝니다. 검색으로 처음 오셨어도 예제는 자급자족합니다.

> 실습 코드: [frontend-testing-lab](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-19) — 이번 편의 `filterByQuery`와 `debounce`는 앞선 편에서 이미 들어와 있어, `step-19`는 `step-18`과 같은 상태입니다. AI 초안과 뮤테이션 실험은 아래 코드 블록만으로 재현됩니다.

이번 편의 목표는 세 가지입니다.

- AI로 테스트 초안 빠르게 뽑기
- AI가 놓치는 케이스(경계·접근성)
- AI 테스트를 검증하는 체크리스트

{{< img src="images/contents/ai-generate-verify.png" alt="AI가 초안을 만들고 사람이 확정하는 순환 흐름 다이어그램 - 함수 코드에 요구사항과 경계 지시, 견본 테스트를 얹어 주면 LLM이 몇 초 만에 그럴듯한 스위트를 뽑아내고, 사람의 검문소에서 뮤테이션 체크·구현 박제 여부·경계와 접근성 포함 여부 세 가지를 통과한 것만 확정합니다. 탈락분은 무엇이 빠졌는지 피드백해 다시 생성하고, 아래에는 AI가 잘 놓치는 공백 검색어·이메일 검색·접근성 항목이 적혀 있습니다" >}}

---

## AI는 좋은 초안러

[단위 테스트 편의 `filterByQuery`](/posts/frontend-testing-unit-basics/)를 주고 "테스트 짜줘"라고 하면, AI는 몇 초 만에 그럴듯한 스위트를 뽑아냅니다. 대개 이런 모양이에요.

```ts
// 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명이라 가정했어요. 우리 데이터를 보여준 적이 없으니 지어낸 겁니다. 숫자가 우연히 맞으면 다행이지만 어긋나면, 멀쩡한 코드에서 테스트가 빨간불이 됩니다. 반복적인 조합을 빠르게 깔아주는 데는 정말 유용하지만, **사실 관계는 처음부터 사람이 쥐고 있어야** 한다는 예고편인 셈이죠.

초안의 질은 **무엇을 주느냐**에 크게 좌우됩니다. 대부분은 이렇게 던지고 말죠.

```text
이 함수 테스트 짜줘

export function filterByQuery(users, query) { ... }
```

받는 쪽 입장에서 생각해보면 당연한 결과입니다. 요구사항도 모르고, 데이터가 몇 건인지도 모르고, 이 프로젝트가 어떤 스타일로 테스트를 쓰는지도 모르니까요. **모르는 건 지어냅니다.** 아까 3명을 2명으로 만든 게 그거예요.

세 가지를 더 얹으면 초안이 달라집니다.

```text
아래 함수의 테스트를 Vitest로 작성해줘.

[요구사항]
- 이름과 이메일 둘 다로 검색된다
- 대소문자를 구분하지 않는다
- 검색어가 비어 있거나 공백뿐이면 전체를 반환한다

[경계 조건도 반드시 포함]
빈 문자열 / 공백만 있는 문자열 / 결과 0건

[이 프로젝트의 테스트 스타일 — 이 문체를 따라줘]
it('빈 검색어는 전체를 반환한다', () => {
  const result = filterByQuery(users, '   ')
  expect(result).toHaveLength(2)
})

[대상 코드]
export function filterByQuery(users, query) { ... }
```

세 블록이 각각 다른 일을 합니다. **요구사항**은 지어내기를 막고, **경계 조건**은 행복 경로만 훑는 습성을 눌러주고, **스타일 견본**은 팀 코드에 그대로 붙일 수 있는 모양으로 뽑아줍니다.

여기에 하나 더 — **데이터를 실제로 보여주세요.** 픽스처를 그대로 붙여넣으면 "users는 몇 명인가" 같은 걸 상상하지 않습니다. 프롬프트가 길어지는 게 아까워 보이지만, 지어낸 숫자 하나 고치는 시간보다 짧습니다.

---

## AI가 잘 놓치는 것

그런데 위 초안엔 구멍이 있어요. 우리가 [단위 테스트 편](/posts/frontend-testing-unit-basics/)에서 직접 잡았던 케이스들이 없죠.

- 공백만 있는 검색어(`'   '`) — `trim()` 검증
- **이메일**로도 검색되는지 (요구사항의 절반!)
- 대소문자 혼합 (`'ALICE'`)

AI는 '행복 경로'에 강하고 **경계·실패·접근성**에 약합니다. 시키지 않으면 [`role="alert"` 검증](/posts/frontend-testing-msw/)이나 [접근 가능한 이름](/posts/frontend-testing-a11y-queries/) 같은 건 거의 안 나와요.

왜 그런지 짐작해보면 이유가 단순합니다. AI는 세상에 널려 있는 코드를 학습했는데, **그 코드들이 원래 경계를 잘 안 챙기거든요.** 예제 코드나 튜토리얼은 대개 잘 되는 경우만 보여주고, 접근성 단언이 들어간 테스트는 그중에서도 소수입니다. 평균을 학습한 결과가 평균적인 테스트로 나오는 셈이죠. 그러니 "알아서 잘 해주겠지"가 아니라 **빠뜨릴 것을 미리 지목해주는 쪽**이 맞습니다.

### 그래서 어떻게 찾아내나

"경계를 챙겨라"는 말만으로는 손이 안 움직입니다. 저는 세 가지를 순서대로 합니다.

**하나, 요구사항을 문장으로 쪼개 하나씩 지웁니다.** 아까 프롬프트에 적었던 요구사항이 세 줄이었죠 — 이름과 이메일 둘 다 검색, 대소문자 무시, 빈 검색어는 전체 반환. 이걸 옆에 펼쳐두고 초안의 테스트와 짝을 지어보면 **이메일 검색에 짝이 없다**는 게 바로 보입니다. AI가 요구사항의 절반을 통째로 빠뜨린 건데, 코드만 들여다봤으면 못 찾았을 거예요. 요구사항이 문서에 없다면 그것부터가 문제고요.

**둘, 매번 같은 경계 목록을 훑습니다.** 저는 이 여섯 개를 고정으로 씁니다 — 빈 값, 공백만, 결과 0건, 최댓값·초과, 중복, 그리고 대소문자·특수문자. 기억에 의존하면 그날 컨디션에 따라 빠지는데, 목록으로 두면 기계적으로 걸러집니다. 초안에서 빠졌던 `'   '`와 `'ALICE'`가 정확히 이 목록의 2번과 6번이에요.

**셋, 접근성은 따로 시킵니다.** 이건 "포함해줘"라고 적지 않으면 거의 안 나옵니다.

```text
위 테스트에 접근성 단언을 추가해줘.
CSS 클래스나 test-id 대신 role과 접근 가능한 이름으로 요소를 찾고,
에러 메시지는 role="alert"로 읽히는지 확인하는 형태로.
```

한 가지 더 — **AI에게 되물어보는 것도 꽤 먹힙니다.** "이 테스트가 놓친 경계 케이스를 나열해줘"라고 하면 자기가 방금 안 쓴 것들을 곧잘 짚어내요. 처음부터 시키면 안 하면서 물어보면 아는, 조금 얄미운 구석이 있습니다. 물론 그 목록도 그대로 믿을 건 아니고 위의 두 단계로 거르면 됩니다.

---

## 검증 체크리스트

AI가 짠 테스트에 사인하기 전, 세 가지만 확인하세요.

1. **뮤테이션 체크**: 코드를 일부러 깨보세요(`trim()` 제거 등). 테스트가 빨간불이 안 되면 그건 장식품입니다.
2. **동작 vs 구현**: 내부 구현(함수 호출 횟수 등)을 박제하고 있진 않은가?
3. **경계·접근성**: 빈 값, 에러 상태, role/이름 검증이 들어갔는가?

1번을 아까 그 AI 초안에 실제로 해봤습니다. 픽스처는 단위 테스트 편에서 쓰던 2명짜리 그대로예요 — 초안의 숫자가 마침 맞아떨어지니 결함 말고는 변수가 없죠. 3막짜리 실험입니다.

```bash
# 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`로 보면 금방 잡힙니다. [비동기 편](/posts/frontend-testing-async/)에서 가짜 타이머로 다뤘던 그 함수예요 — 연달아 부르면 마지막 것만 실행되는.

```ts
// 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` 내부를 타이머 취소 대신 세대 카운터 방식으로 바꿔봤습니다. 나중에 부른 호출이 이기게 하는 건 똑같고, 밖에서 보는 동작도 그대로예요.

```ts
// 동작은 같고 만드는 방식만 다르다 — clearTimeout을 쓰지 않는다
let generation = 0
return (...args: Parameters<T>) => {
  const mine = ++generation
  setTimeout(() => { if (mine === generation) fn(...args) }, ms)
}
```

바꾸고 돌린 결과입니다.

```bash
     × 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`가 잔뜩 붙어 있으면 한 번씩 물어보세요 — 이게 약속인가, 아니면 지금 그렇게 짜여 있을 뿐인가.

{{< img src="images/contents/mutation-check.png" alt="뮤테이션 체크의 원리 다이어그램 - AI가 생성한 테스트가 전부 초록불인 상태에서 코드에 trim 제거 같은 결함을 일부러 심습니다. 그래도 전부 초록불이면 그 테스트는 결함을 못 잡는 장식품이고, 빨간불이 켜지면 진짜 경보기임이 증명됩니다. 시리즈 내내 해온 일부러 부숴봅시다의 정식 명칭이 뮤테이션 테스트라는 설명이 붙어 있습니다" >}}

---

> 생성은 위임할 수 있어도 **책임은 위임할 수 없어요.** 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·자가치유 셀렉터

> 낯선 용어가 있었다면 — [용어집](/posts/frontend-testing-glossary/)에 전부 한 줄씩 정리돼 있어요.

