Development··3분 읽기·3

AI 코드를 신뢰하는 방법: 테스트 전략

테스트는 버그를 잡는 게 아니라 AI를 통제하는 수단이다. 바이브 코딩 시대의 테스트 피라미드와 실전 전략을 다룬다.

글꼴

AI가 만든 코드를 어떻게 믿을 수 있나?

지난 글들에서 계속 "검증"이 중요하다고 했다. 이번 글에서는 그 검증을 어떻게 하는지, 테스트 전략을 구체적으로 다룬다.


테스트의 목적이 달라졌다

전통적으로 테스트는 "내가 짠 코드가 맞는지" 확인하는 용도였다. 내가 로직을 알고 있으니까, 그 로직이 의도대로 동작하는지 검증.

바이브 코딩에서는 다르다. 내가 코드를 안 짰다. AI가 짰다. 그래서 테스트의 목적이 달라진다.

  • 전통: 내 코드가 의도대로 동작하는지 확인
  • 바이브: AI 코드가 내 의도를 제대로 구현했는지 확인

미묘한 차이 같지만 중요하다. AI 코드는 "그럴듯해 보이는데 구멍이 있는" 경우가 많다. 테스트는 그 구멍을 찾는 수단이다.


테스트 피라미드 재해석

전통적인 테스트 피라미드:

        /\
       /  \      E2E (적음)
      /____\
     /      \    통합 (중간)
    /________\
   /          \  유닛 (많음)
  /____________\

유닛 테스트가 많고, 위로 갈수록 적어진다. 이게 교과서적인 답이었다.

바이브 코딩에서는?

        /\
       /  \      E2E (핵심만)
      /____\
     /      \    통합 (많음) ← 강조
    /________\
   /          \  유닛 (선택적)
  /____________\

통합 테스트가 더 중요해진다. 왜냐면:

  1. 유닛 테스트: AI가 만든 내부 구현을 테스트해봤자, 내가 그 구현을 모른다
  2. 통합 테스트: "이 API 호출하면 이 결과 나와야 해"는 명확하다
  3. E2E: 비용이 크지만, 핵심 흐름은 반드시 커버

유닛 테스트: 선택적으로

AI 코드의 유닛 테스트는 생각보다 가치가 낮다.

// AI가 만든 함수
function calculateDiscount(price: number, userLevel: string) {
  if (userLevel === 'VIP') return price * 0.8
  if (userLevel === 'GOLD') return price * 0.9
  return price
}
 
// 유닛 테스트
test('VIP는 20% 할인', () => {
  expect(calculateDiscount(100, 'VIP')).toBe(80)
})

이 테스트가 의미 있으려면, 내가 "VIP는 20% 할인"이라는 스펙을 알아야 한다. 근데 그 스펙을 알면 구현도 내가 확인할 수 있다.

유닛 테스트가 유용한 경우:

  • 복잡한 계산 로직
  • 경계값 처리가 중요한 경우
  • 여러 곳에서 재사용되는 유틸 함수

유닛 테스트가 불필요한 경우:

  • 단순한 CRUD
  • 프레임워크가 처리하는 로직
  • 구현 세부사항이 자주 바뀌는 부분

통합 테스트: 핵심

AI 코드 검증에서 통합 테스트가 핵심인 이유:

  1. 입출력이 명확하다: "이 요청 보내면 이 응답 와야 해"
  2. 내부 구현 몰라도 된다: AI가 어떻게 짰든, 결과만 맞으면 됨
  3. 실제 사용 환경과 비슷하다: 유닛보다 현실적

통합 테스트 예시

// API 통합 테스트
describe('POST /api/login', () => {
  test('올바른 정보로 로그인 성공', async () => {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email: 'test@example.com', password: 'password123' })
    })
    const data = await response.json()
 
    expect(response.status).toBe(200)
    expect(data.user).toBeDefined()
    expect(data.token).toBeDefined()
  })
 
  test('존재하지 않는 이메일', async () => {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email: 'nobody@example.com', password: 'password123' })
    })
    const data = await response.json()
 
    expect(response.status).toBe(401)
    expect(data.code).toBe('USER_NOT_FOUND')
  })
 
  test('비밀번호 틀림', async () => {
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email: 'test@example.com', password: 'wrong' })
    })
    const data = await response.json()
 
    expect(response.status).toBe(401)
    expect(data.code).toBe('INVALID_PASSWORD')
  })
})

이 테스트들은 Spec에서 정의한 케이스를 그대로 검증한다. AI 구현이 어떻든, 이 테스트가 통과하면 스펙대로 동작하는 거다.


E2E 테스트: 핵심 흐름만

E2E는 비용이 크다. 느리고, 불안정하고, 유지보수도 힘들다. 그래서 핵심 사용자 흐름만 커버한다.

핵심 흐름 정의

"우리 서비스에서 이게 안 되면 망한다" 싶은 것들:

블로그 예시:
1. 홈페이지 → 포스트 목록 보임
2. 포스트 클릭 → 내용 보임
3. 검색 → 결과 나옴

쇼핑몰 예시:
1. 상품 목록 → 상품 보임
2. 상품 클릭 → 상세 보임
3. 장바구니 담기 → 담김
4. 결제 → 주문 생성

이 흐름들만 E2E로 커버하고, 나머지는 통합 테스트로.

E2E 예시 (Playwright)

test('포스트 조회 흐름', async ({ page }) => {
  // 홈페이지
  await page.goto('/')
  await expect(page.locator('h1')).toContainText('블로그')
 
  // 포스트 목록 확인
  const posts = page.locator('[data-testid="post-item"]')
  await expect(posts.first()).toBeVisible()
 
  // 첫 번째 포스트 클릭
  await posts.first().click()
 
  // 포스트 내용 확인
  await expect(page.locator('article')).toBeVisible()
})

"깨지면 안 되는 지점" 찾기

커버리지 100%를 목표로 하면 망한다. 대신 "이게 깨지면 진짜 문제" 인 지점을 찾는다.

질문해보기

  1. 이 기능이 안 되면 사용자가 뭘 못 하나?

    • 로그인 안 됨 → 아무것도 못 함 → 반드시 테스트
    • 다크모드 안 됨 → 불편함 → 선택적
  2. 이 버그가 나면 돈을 잃나?

    • 결제 금액 계산 오류 → 돈 잃음 → 반드시 테스트
    • 날짜 포맷 이상함 → 불편함 → 선택적
  3. 이 기능이 조용히 깨질 수 있나?

    • API 응답 형식 변경 → 조용히 깨짐 → 반드시 테스트
    • 버튼 색상 이상함 → 바로 보임 → 선택적

AI에게 테스트 작성 시키기

테스트도 AI에게 시킬 수 있다. 근데 주의할 점이 있다.

잘못된 방법

"이 코드에 대한 테스트 작성해줘"
→ AI가 구현에 맞춘 테스트를 작성함
→ 구현이 틀려도 테스트가 통과할 수 있음

올바른 방법

"이 Spec에 대한 테스트를 작성해줘"

[Spec]
- POST /api/login
- 성공: 200, { user, token }
- 실패(사용자 없음): 401, { code: 'USER_NOT_FOUND' }
- 실패(비밀번호 틀림): 401, { code: 'INVALID_PASSWORD' }

구현 코드는 참조하지 말고, Spec만 보고 테스트를 작성해.

Spec 기반으로 테스트를 작성하면, 구현과 독립적인 테스트가 나온다. 이게 AI 코드를 검증하는 올바른 방법이다.


테스트 자동화 파이프라인

테스트를 작성했으면 자동으로 돌아야 한다.

[로컬]
코드 수정 → 저장 → 관련 테스트 자동 실행

[CI]
PR 생성 → 전체 테스트 실행 → 통과해야 머지

권장 설정

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:watch": "vitest watch",
    "test:ci": "vitest run --coverage"
  }
}
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run test:ci

실패하는 테스트 다루기

AI 코드로 테스트가 실패하면:

  1. 테스트가 틀린 건가, 코드가 틀린 건가?

    • Spec 다시 확인
    • 테스트가 Spec을 제대로 반영하는지 확인
  2. 코드가 틀렸으면

    테스트 실패 결과:
    - 예상: 401, { code: 'USER_NOT_FOUND' }
    - 실제: 500, { error: 'Internal Server Error' }
    
    존재하지 않는 사용자로 로그인할 때 500이 아니라
    401과 USER_NOT_FOUND를 반환하도록 수정해줘.
    
  3. 테스트가 틀렸으면

    • Spec을 수정하거나
    • 테스트를 Spec에 맞게 수정

현실적인 테스트 전략

"이상적인" 테스트 전략 말고, 실제로 할 수 있는 걸 정리하면:

MVP/프로토타입 단계

  • E2E: 핵심 1~2개 흐름만
  • 통합: API 성공/실패 기본 케이스만
  • 유닛: 없어도 됨

성장 단계

  • E2E: 핵심 흐름 + 돈 관련
  • 통합: 모든 API의 주요 케이스
  • 유닛: 복잡한 로직만

안정화 단계

  • E2E: 주요 사용자 시나리오 전체
  • 통합: 모든 케이스
  • 유닛: 재사용 로직

마무리

바이브 코딩에서 테스트는 선택이 아니다. AI 코드를 신뢰하려면 테스트가 있어야 한다.

핵심:

  1. 통합 테스트 중심 - 입출력으로 검증
  2. Spec 기반 - 구현 말고 Spec을 테스트
  3. 핵심 흐름 우선 - 커버리지보다 중요도
  4. 자동화 - 수동으로 하면 안 함

다음 편에서는 1인 개발자가 이 모든 걸 어떻게 운영하는지, 시스템을 다룬다.

바이브 코딩은 자유를 주지만, 책임은 사라지지 않는다.

이 글이 어떠셨나요?

이 글이 도움이 되셨나요?
공유:

관련 포스트

뉴스레터 구독

새 글이 올라오면 이메일로 알려드려요.

댓글

댓글을 불러오는 중...