「프론트엔드 테스트 제대로 하기」 시리즈의 21편입니다. 전체 목차 보기 · 용어집

AI가 짠 코드는 빠르고, 돌아가고, 화면도 예쁩니다. 문제는 그럴듯하게 틀린다는 것이에요.

특히 두 군데가 자주 비어 있습니다. 하나는 접근성 — 화면을 보지 않고 웹을 쓰는 사람에게 필요한 것들이요. 다른 하나는 검색어를 비운 채 눌렀을 때, 서버가 500을 뱉었을 때처럼 평소엔 잘 안 일어나는 순간입니다. 이런 걸 엣지케이스라고 불러요. 둘 다 “안 시키면 안 챙기는” 영역이죠.

이 글은 AI 생성 코드가 반복해서 빠뜨리는 패턴을 모으고, 테스트가 그 구멍을 어떻게 자동으로 고발하는지 실제 Before/After로 보여줍니다. 흐름을 타고 빠르게 만드는 요즘 방식 — 바이브 코딩에 매는 안전벨트인 셈이에요.

시리즈를 따라오셨다면 그 문제의식이 한 편으로 모이는 곳이고, 검색으로 처음 오셨어도 표와 예제만으로 완결됩니다. (여기 나오는 테스트 기법들이 궁금해지면 시리즈 목차로.)

실습 코드: frontend-testing-lab — 본문의 계약 테스트는 직접 넣어보시라고 일부러 담지 않았습니다. 그래서 step-21은 앞 편과 같은 상태예요.

오늘 확인할 것들입니다.

  • AI 코드가 반복적으로 놓치는 패턴
  • 이 시리즈의 테스트로 그 함정을 잡는 법
  • ‘빠른 생성’과 ‘느린 검증’의 균형
AI 생성 코드가 자주 놓치는 것과 이를 잡아내는 테스트를 짝지어 보여주는 다이어그램 - 왼쪽은 AI가 짠 검색 UI로 돌아가긴 하지만 label 없음, div 버튼, alert 없음, 빈 검색어·서버 오류 미처리가 표시돼 있고, 오른쪽 방어선에서는 getByLabelText, getByRole, findByRole alert, 경계 단위 테스트가 차례로 실패로 고발하며 axe 스캔이 대비·ARIA 위반을 더합니다. 아래에는 AI로 빠르게 생성하고 테스트로 꼼꼼히 검증하는 2단 구성이라는 결론이 붙어 있습니다
AI 생성 코드가 자주 놓치는 것과 이를 잡아내는 테스트를 짝지어 보여주는 다이어그램 - 왼쪽은 AI가 짠 검색 UI로 돌아가긴 하지만 label 없음, div 버튼, alert 없음, 빈 검색어·서버 오류 미처리가 표시돼 있고, 오른쪽 방어선에서는 getByLabelText, getByRole, findByRole alert, 경계 단위 테스트가 차례로 실패로 고발하며 axe 스캔이 대비·ARIA 위반을 더합니다. 아래에는 AI로 빠르게 생성하고 테스트로 꼼꼼히 검증하는 2단 구성이라는 결론이 붙어 있습니다

AI가 반복해서 빠뜨리는 것들 — 접근성과 엣지케이스

AI에게 “검색 UI 만들어줘"라고 하면, 높은 확률로 이런 코드가 나옵니다.

tsx
// AI가 자주 뽑는 패턴 (그럴듯하지만 구멍이 많다)
<div className="search">
  <input placeholder="검색..." onChange={...} />   {/* label 없음 */}
  <div className="clear-btn" onClick={clear}></div> {/* div 버튼 */}
</div>
{error && <p className="error">{error}</p>}          {/* role="alert" 없음 */}

돌아가긴 해요. 화면도 예쁩니다. 하지만 라벨 없는 입력, div 버튼, 알림 없는 에러 — 스크린 리더 사용자에겐 이고, 빈 검색어·서버 오류 같은 엣지케이스 처리도 대개 빠져 있죠.

패턴을 모아두면 리뷰가 빨라집니다. 제가 반복해서 만나는 것들이에요.

데이터 표
빠뜨린 것화면에서는실제로는
label 없는 입력 (placeholder로 대체)멀쩡함이름이 placeholder에 얹혀 버팀 — 입력을 시작하면 그 글자가 사라져 무슨 칸이었는지 확인할 길이 없음
divonClick버튼처럼 보임탭으로 도달 불가, 엔터·스페이스로 눌리지 않음, 버튼으로 안 읽힘
알림 없는 에러 메시지빨간 글씨가 보임화면을 보지 않는 사용자에게는 아무 일도 안 일어난 것
색으로만 구별하는 상태초록/빨강으로 명확색각 이상 사용자에게 구별 불가 (WCAG 1.4.1)
사라지는 요소의 포커스 방치티가 안 남지우기 버튼이 사라지면 포커스가 <body>로 떨어져 위치를 잃음
빈 결과에 아무 안내 없음그냥 비어 보임검색이 안 된 건지, 결과가 없는 건지, 로딩 중인지 알 수 없음
로딩 중 중복 제출 가능눌리는 게 자연스러움같은 요청이 여러 번, 서버 상태가 꼬임

공통점이 보이시나요. 전부 눈으로 봐서는 알 수 없는 것들입니다. AI는 화면에 그려지는 결과를 최적화하지, 화면을 보지 않는 사용자나 예외적인 순간을 최적화하지 않아요. 그래서 눈으로 하는 검수는 이 목록을 거의 통과시킵니다.

AI가 만든 화면을 눈으로 훑고 “오, 잘했네” 하며 머지 버튼을 누른 적, 저는 있습니다. 화면이 멀쩡하면 통과시키는 검수는 딱 화면만큼만 지켜주더라고요.


테스트로 방어 — 어느 구멍을 어느 테스트가 잡나

앞 편들에서 만든 무기들이 정확히 이 구멍들을 겨냥합니다. 왼쪽이 방금 본 누락이고, 오른쪽이 그걸 걸러내는 테스트예요. 함수 이름이 낯설어도 지금은 괜찮습니다 — “화면을 보지 않고 확인하는 방법이 이미 다 있다"만 가져가시면 돼요.

데이터 표
AI가 놓친 것잡아내는 테스트
label 없는 입력getByLabelText('검색') 실패 — 철학 편
div 버튼getByRole('button', { name: ... }) 실패 — 접근성 쿼리 편
알림 없는 에러findByRole('alert') 실패 — MSW 편
대비·ARIA 위반axe 스캔 실패 — 접근성 E2E 편
빈 값·서버 오류경계 단위 테스트 실패 — 단위·비동기 편

이런 테스트가 쌓여 있으면 — 프로젝트에 모아둔 테스트 전체를 스위트라고 부릅니다 — AI가 짠 코드를 넣어보는 순간 어디가 부족한지 빨간불이 짚어줍니다.

한 쌍 보시죠. 지우기 버튼을 AI가 짜면 —

tsx
// Before: AI 초안
<div className="clear-btn" onClick={clearQuery}></div>

실제로 데모 앱의 버튼을 이 div로 바꿔서 스위트를 돌려봤습니다.

bash
× 지우기 버튼은 접근 가능한 이름을 가진 버튼이다
  TestingLibraryElementError: Unable to find an accessible element
  with the role "button" and name "검색어 지우기"

role 쿼리가 곧장 고발하죠. 그런데 여기 반전이 하나 있어요 — 같은 상태에서 axe 스캔은 위반 0건으로 통과했습니다.

axe는 페이지의 마크업을 훑어 접근성 규칙 위반을 찾아주는 자동 검사 도구예요. 규칙을 보는 도구다 보니, div에 걸어둔 클릭 핸들러가 “사실은 이게 버튼"이라는 사정까지는 알 길이 없습니다. 접근성 E2E 편의 “자동 검사는 바닥만 지킨다"가 정확히 이 장면이에요. 이 구멍을 잡아낸 건 결국 role 쿼리 하나였습니다 — 안전망을 겹쳐 치는 이유가 여기 있어요.

tsx
// After: 빨간불을 따라가며 고친 코드
<button type="button" aria-label="검색어 지우기" onClick={clearQuery}>
  
</button>
// 테스트 통과, 스크린 리더 "검색어 지우기, 버튼"

고치는 데 걸린 시간은 1분. 테스트가 없었다면 이 문제는 어느 사용자의 막힌 화면으로만 존재했을 거예요.

AI 초안 코드가 테스트 방어선에서 수리되는 과정 다이어그램 - 왼쪽 Before는 div로 만든 지우기 버튼으로 화면은 멀쩡하지만, 가운데 방어선에서 getByRole 실패가 빨간불로 지점을 지목하고, axe 스캔은 클릭 핸들러를 볼 수 없어 위반 0건으로 통과해버리는 사각지대가 함께 표시됩니다. 오른쪽 After는 aria-label을 가진 button으로 고쳐져 테스트를 통과하고 스크린 리더가 검색어 지우기 버튼이라고 읽습니다. 수리에 걸린 시간은 1분이라는 설명이 붙어 있습니다
AI 초안 코드가 테스트 방어선에서 수리되는 과정 다이어그램 - 왼쪽 Before는 div로 만든 지우기 버튼으로 화면은 멀쩡하지만, 가운데 방어선에서 getByRole 실패가 빨간불로 지점을 지목하고, axe 스캔은 클릭 핸들러를 볼 수 없어 위반 0건으로 통과해버리는 사각지대가 함께 표시됩니다. 오른쪽 After는 aria-label을 가진 button으로 고쳐져 테스트를 통과하고 스크린 리더가 검색어 지우기 버튼이라고 읽습니다. 수리에 걸린 시간은 1분이라는 설명이 붙어 있습니다

적어두지 않은 약속은, 테스트도 못 잡습니다

여기까지만 보면 “테스트만 있으면 되겠네” 싶은데, 한 가지를 더 확인해야 공정합니다. 테스트는 우리가 적어둔 약속만 지킵니다. 적지 않은 약속은 AI가 어겨도 조용해요.

위 표의 여섯 번째, 빈 결과 안내로 실험해봤습니다. 데모 앱에는 검색 결과가 없을 때 “검색 결과가 없습니다.“를 띄우는 분기가 있는데, AI 초안이 흔히 빠뜨리는 자리죠. 그 분기를 통째로 지우고 전체 스위트를 돌렸습니다.

bash
# 빈 결과 안내를 제거한 상태 — 단위·컴포넌트
  Test Files  5 passed (5)
       Tests  13 passed (13)

# E2E(axe 접근성 스캔 포함)
  5 passed (4.0s)

18개 전부 초록불입니다. 사용자는 검색해놓고 텅 빈 화면을 마주하는데, 로딩 중인지 결과가 없는 건지 알 수 없는 상태예요. 앞에서 div 버튼을 잡아냈던 그 스위트가, 이번엔 아무 말도 하지 않습니다.

이유는 단순합니다. div 버튼은 “지우기 버튼은 접근 가능한 이름을 가진 버튼이다"라는 약속이 테스트로 적혀 있었고, 빈 결과 안내는 적혀 있지 않았거든요. 이렇게 테스트로 못 박아둔 약속을 계약이라고 부릅니다. 계약을 하나 추가하면 곧바로 잡혀요.

tsx
it('결과가 없으면 그 사실을 알린다', async () => {
  const user = userEvent.setup()
  render(<UserSearch />)
  await screen.findByText('Alice Kim')

  await user.type(screen.getByLabelText('검색'), 'zzz')

  expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument()
})
bash
× 결과가 없으면 그 사실을 알린다
  TestingLibraryElementError: Unable to find an element
  with the text: 검색 결과가 없습니다.

그래서 결론이 조금 달라집니다. AI 코드를 막아주는 건 “테스트가 있다"가 아니라 “그 약속이 테스트로 적혀 있다”예요. 테스트 스위트는 자동으로 촘촘해지지 않습니다. 무엇을 약속으로 삼을지는 여전히 사람이 정해요 — 19편에서 요구사항을 문장으로 쪼개 하나씩 짝지어본 게 정확히 이 작업이었습니다.

직접 해보실 분께: 이 테스트는 데모 저장소에 일부러 넣지 않았습니다. 위 코드를 src/components/UserSearch.test.tsx에 추가하고, 컴포넌트에서 빈 결과 분기를 지웠다 되돌려보세요. 빨간불과 초록불이 오가는 걸 직접 보는 게 이 편에서 가장 남는 경험일 겁니다.


바이브 코딩과 검증의 균형 — 병목은 이미 옮겨갔습니다

AI로 빠르게 만들고, 테스트로 꼼꼼히 검증하는 2단 구성이 현실적인 답이에요. 프롬프트에 “접근성 고려해서"를 넣는 것도 도움이 되지만, 그걸 강제하는 건 결국 테스트와 CI 게이트입니다. 생성 속도만 자랑하는 팀은 결국 프로덕션에서 비용을 치릅니다.

생각해보면 병목이 옮겨간 거예요. 예전엔 코드를 쓰는 게 느렸는데, 지금은 쓰는 건 몇 초고 믿을 수 있는지 확인하는 게 느립니다. 그런데 많은 팀이 아직 예전 배분을 유지하고 있어요 — 생성에 쓰던 시간만 줄이고, 검증에는 그대로 몇 분만 쓰는 거죠. 그러면 리뷰가 “돌아가는지” 확인하는 수준에 머물고, 위 표의 일곱 가지는 전부 통과합니다.

앞에서 말한 바이브 코딩을 나무랄 생각은 없어요. 저도 그 속도감을 좋아합니다. 다만 흐름이 끊기지 않으려면 뒤에서 대신 멈춰 세워주는 장치가 있어야 해요. 테스트와 CI가 그 역할입니다. 안전벨트를 매면 오히려 편하게 밟을 수 있는 것과 같아요 — 벨트가 없으면 결국 속도를 스스로 줄이게 되거든요.

사람이 봐야 할 것도 조금 달라집니다. 문법이나 오타는 이제 볼 필요가 거의 없어요. 대신 “이 코드가 무엇을 약속하는가”를 봐야 합니다. 라벨과 role이 사용자가 부르는 이름과 맞는지, 실패했을 때 무슨 일이 일어나는지, 아무것도 없을 때 무엇이 보이는지 — AI가 알아서 정해준 이 결정들이 사실은 제품 결정이거든요.


한 장 요약

  • AI 코드의 단골 구멍: 라벨 없는 입력 · div 버튼 · 알림 없는 에러 · 빈 값/서버 오류 미처리 — 화면은 멀쩡해 보임
  • 테스트 스위트가 있으면 AI 코드를 넣어보는 순간 어느 구멍인지 빨간불이 가리킴 (role 쿼리·alert·axe·경계 테스트)
  • 단, div 버튼은 axe도 못 봅니다(클릭 핸들러는 스캔에 안 보임, 실측 위반 0건) — 그 구멍은 role 쿼리 계약의 몫
  • 적어두지 않은 약속은 스위트도 못 잡습니다 — 빈 결과 안내를 지웠더니 18개 테스트가 전부 초록불(axe 포함). 계약 테스트 한 줄을 더하자 곧장 빨간불
  • 고치는 비용은 분 단위 — 테스트가 없었다면 그 구멍은 어느 사용자의 막힌 화면으로만 존재했을 것
  • 프롬프트에 “접근성 고려"를 넣는 건 도움, 강제하는 건 테스트와 CI 게이트
  • 현실적 균형 = AI로 빠르게 생성 + 테스트로 꼼꼼히 검증하는 2단 구성

방어선을 점검하며

긴 여정의 마지막이 다가옵니다. 다음 편에서 시리즈를 회고하고, 다음 단계를 이야기할게요.

AI에게 “접근성 챙겨서 짜줘"라고 하면 정말 챙겨줍니다. 문제는 안 시키면 안 챙긴다는 거죠. 그 ‘안 시켰을 때’를 잡는 게 테스트예요.

레벨업: AI 코드의 전형적인 함정을 테스트로 걸러내는 안전망을 갖췄습니다.

다음 편: 테스트 문화·면접·다음 단계 — 시리즈를 마치며

낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.