테스팅 전략의 전환
테스트를 AI 구현 위임의 작업 지시서로 삼는 전환과, 동작 명세 테스트·통합 테스트 비중 확대, AI가 테스트를 쓸 때의 함정을 다루는 장
핵심 요약
- 테스트를 먼저 쓰면 구현을 AI에게 맡길 수 있다. 테스트가 사후 검증이 아니라 사전 명세, 곧 작업 지시서가 된다.
- 구현에 종속된 테스트 말고 동작을 명세하는 테스트를 써야 AI가 구현을 자유롭게 고르고, 리팩토링해도 깨지지 않는다.
- AI에게 테스트를 맡기면 자기 코드의 버그를 정답으로 굳히거나, 해피 패스만 만들거나, toBeDefined()로 검증하는 시늉만 하는 함정이 있다.
- 테스트 피라미드도 다시 짜인다. AI가 모듈 사이 연결을 자주 틀리는 탓에 통합 테스트 비중이 25%에서 40%로 올라간다.
- 잘 쓴 테스트는 실행해서 검증되는 '살아 있는 기능 명세서'가 되고, 기획 문서보다 믿을 만하다.
TDD는 오래된 개념이다. 하지만 대부분의 개발자는 실제로 "구현 먼저, 테스트 나중에" 순서로 일한다. AI 시대에 이 순서가 뒤집히는 이유는 단순하다. 테스트를 먼저 쓰면 AI에게 구현을 맡길 수 있어서다.
전통적 테스팅과 AI 시대 테스팅의 흐름 차이
대부분의 팀이 실제로 따르는 테스팅 흐름과 AI 시대의 흐름을 나란히 놓으면 구조가 어떻게 다른지 보인다.
전통적 흐름에는 이런 문제가 있다.
| 문제 | 설명 | AI 시대 해결 방식 |
|---|---|---|
| 테스트가 구현에 종속 | 구현 방식을 검증하게 됨 | 테스트가 먼저이므로 동작을 검증 |
| 테스트 작성 동기 부족 | 동작하는 코드에 테스트는 귀찮은 추가 작업 | 테스트가 AI의 작업 지시서 |
| 엣지 케이스 누락 | 구현 시 생각 못한 것을 테스트에서도 놓침 | 테스트 설계 단계에서 엣지 케이스 먼저 도출 |
| 리팩토링 부담 | 구현 밀착 테스트는 리팩토링마다 깨짐 | 동작 기반 테스트는 리팩토링에 견고 |
테스트가 작업 지시서가 되는 구조
전환의 핵심은 테스트의 역할이 사후 검증에서 사전 명세로 바뀐다는 데 있다.
이 구조가 강력한 세 가지 이유
테스트를 먼저 쓰면 세 가지를 한꺼번에 얻는다.
- 명확한 스펙: AI에게 모호한 자연어 대신 실행 가능한 명세를 제공
- 자동 검증: AI가 생성한 코드를 즉시 검증할 수 있는 기준
- 리팩토링 안전망: 나중에 AI에게 리팩토링을 시켜도 테스트가 정상 동작을 보장
구현 종속 테스트와 동작 명세 테스트의 차이
테스트를 AI의 작업 지시서로 쓰려면 구현 방식이 아니라 동작을 명세해야 한다.
구현에 종속된 테스트의 문제
// 내부 구현을 알고 있어야 작성 가능 - AI의 자유도를 제한
test('calculateDiscount가 Map에서 쿠폰을 조회한다', () => {
const couponMap = new Map([['VIP10', 0.1]]);
const service = new DiscountService(couponMap);
expect(service.calculateDiscount('VIP10', 10000)).toBe(9000);
});동작을 명세하는 테스트
// AI가 어떤 구현이든 자유롭게 선택할 수 있음
describe('할인 계산', () => {
test('유효한 쿠폰 코드로 할인이 적용된다', () => {
const result = applyDiscount({ code: 'VIP10', amount: 10000 });
expect(result.finalAmount).toBe(9000);
expect(result.discountApplied).toBe(true);
});
test('만료된 쿠폰은 할인이 적용되지 않는다', () => {
const result = applyDiscount({ code: 'EXPIRED', amount: 10000 });
expect(result.finalAmount).toBe(10000);
expect(result.discountApplied).toBe(false);
expect(result.reason).toBe('쿠폰이 만료되었습니다');
});
test('할인 후 금액은 0원 미만이 될 수 없다', () => {
const result = applyDiscount({ code: 'FULL100', amount: 500 });
expect(result.finalAmount).toBe(0);
});
test('쿠폰 코드가 빈 문자열이면 에러를 반환한다', () => {
expect(() => applyDiscount({ code: '', amount: 10000 }))
.toThrow('유효하지 않은 쿠폰 코드');
});
});두 번째 예시라면 "이 테스트를 통과하는 applyDiscount 함수를 구현해"라고 AI에게 요청하면 끝이다. 구현 방식은 AI에게 맡기고, 동작이 맞는지는 테스트가 보장한다.
| 비교 항목 | 구현 종속 테스트 | 동작 명세 테스트 |
|---|---|---|
| AI 자유도 | 낮음 - 특정 클래스 강제 | 높음 - 구현 방식 자유 |
| 리팩토링 내성 | 낮음 - 구현 바뀌면 깨짐 | 높음 - 동작이 같으면 통과 |
| 문서 가치 | 낮음 - 구현 세부사항 | 높음 - 비즈니스 규칙 명세 |
| 엣지 케이스 포함 | 대개 없음 | 자연스럽게 포함 |
실전: 테스트 먼저 쓰고 AI에게 구현 위임하기
실제 작업 흐름을 단계별로 보자.
1단계: 비즈니스 규칙을 테스트로 작성
// 사람이 작성: 주문 취소 정책의 비즈니스 규칙
describe('주문 취소 정책', () => {
test('결제 완료 후 24시간 이내 취소하면 전액 환불', async () => {
const order = createTestOrder({
paidAt: hoursAgo(12),
amount: 50000,
});
const result = await cancelOrder(order.id);
expect(result.refundAmount).toBe(50000);
expect(result.refundType).toBe('FULL');
});
test('결제 완료 후 24시간 초과 시 환불 불가', async () => {
const order = createTestOrder({
paidAt: hoursAgo(25),
amount: 50000,
});
const result = await cancelOrder(order.id);
expect(result.refundAmount).toBe(0);
expect(result.reason).toBe('24시간 초과');
});
test('배송 시작된 주문은 반품 절차 안내', async () => {
const order = createTestOrder({
paidAt: hoursAgo(5),
status: 'SHIPPING',
});
const result = await cancelOrder(order.id);
expect(result.action).toBe('RETURN_PROCESS');
expect(result.returnGuideUrl).toBeDefined();
});
test('이미 취소된 주문은 중복 취소 방지', async () => {
const order = createTestOrder({ status: 'CANCELLED' });
await expect(cancelOrder(order.id))
.rejects.toThrow('이미 취소된 주문');
});
test('부분 환불 기간에는 수수료 10%를 제외하고 환불', async () => {
const order = createTestOrder({
paidAt: hoursAgo(20),
amount: 50000,
});
// 부분 환불 기간: 12-24시간
const result = await cancelOrder(order.id, { partial: true });
expect(result.refundAmount).toBe(45000);
expect(result.fee).toBe(5000);
});
});2단계: AI에게 구현 위임
위 테스트를 모두 통과하는 cancelOrder 함수를 구현해줘.
컨텍스트:
- Prisma ORM 사용, Order 모델에 paidAt, status, amount 필드 있음
- 환불 처리는 refundService.process()로 위임
- 시간 계산은 dayjs 사용, KST 기준
제약:
- 트랜잭션 내에서 상태 변경과 환불 처리를 함께 수행
- 동시에 같은 주문을 취소하는 경우 방지 (낙관적 잠금)3단계: AI 결과 검증 후 테스트 보강
AI가 구현을 내놓으면 테스트를 돌려보고, 통과하지 못하는 케이스가 있으면 프롬프트를 다듬는다. 전부 통과하더라도 빠진 엣지 케이스가 없는지 반드시 점검한다.
// 3단계에서 추가하는 엣지 케이스
test('주문이 존재하지 않으면 NotFoundError', async () => {
await expect(cancelOrder('non-existent-id'))
.rejects.toThrow('주문을 찾을 수 없습니다');
});
test('동시 취소 요청 시 하나만 성공한다', async () => {
const order = createTestOrder({ paidAt: hoursAgo(1) });
const results = await Promise.allSettled([
cancelOrder(order.id),
cancelOrder(order.id),
]);
const fulfilled = results.filter(r => r.status === 'fulfilled');
const rejected = results.filter(r => r.status === 'rejected');
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
});AI가 테스트를 작성할 때의 함정
가장 위험한 패턴: 자기 코드를 검증하는 테스트
AI에게 "이 함수의 테스트를 작성해"라고 시키면 AI는 자기가 이해한 동작을 검증하는 테스트를 만든다. 코드에 버그가 있으면 테스트도 같은 버그를 정답으로 받아들인다.
함정 1: 구현을 그대로 복제하는 테스트
// AI가 구현한 함수
function formatUserName(first: string, last: string): string {
return `${first} ${last}`; // 한국어에서는 성+이름 순서가 맞음
}
// 같은 AI가 작성한 테스트 - 같은 가정을 공유
test('이름을 포매팅한다', () => {
expect(formatUserName('길동', '홍')).toBe('길동 홍');
// 한국어 이름 순서 오류를 테스트도 함께 놓침
// 올바른 기대값: '홍길동' 또는 '홍 길동'
});AI가 만든 구현과 테스트가 같은 가정을 공유하는 맹점을 잘 보여주는 사례다.
함정 2: 해피 패스만 테스트
AI는 정상 동작 테스트는 잘 만들지만 실패 시나리오와 경계 조건을 자주 빠뜨린다.
| AI가 잘 만드는 테스트 | AI가 놓치는 테스트 |
|---|---|
| 정상 입력에 대한 정상 출력 | 네트워크 실패 시 재시도 로직 |
| 기본 CRUD 동작 | 동시 수정 충돌 처리 |
| 단일 사용자 시나리오 | 다중 사용자 동시 접근 |
| 유효한 데이터 | 악의적 입력 |
| 소량 데이터 처리 | 대량 데이터 성능 |
함정 3: 실제 검증 없는 형식적 테스트
AI가 만든 테스트에서 자주 보이는 문제 패턴이다.
// 함정: toBeDefined()만으로 검증 - 어떤 값이든 통과
test('사용자를 조회한다', async () => {
const user = await getUser('user-1');
expect(user).toBeDefined(); // null만 아니면 통과
expect(user.name).toBeDefined(); // 이름이 뭐든 통과
expect(user.email).toBeDefined(); // 이메일 형식 검증 없음
});
// 올바른 검증: 구체적 값과 형식을 확인
test('사용자를 조회한다', async () => {
const user = await getUser('user-1');
expect(user.name).toBe('홍길동');
expect(user.email).toMatch(/^[^@]+@[^@]+\.[^@]+$/);
expect(user.role).toBe('MEMBER');
expect(user.createdAt).toBeInstanceOf(Date);
});toBeDefined()만 쓰는 테스트는 "존재하기만 하면 통과"라서 사실상 검증이 아니다.
함정 4: 과도하게 많은 테스트
AI에게 "테스트를 철저하게 작성해"라고 하면 의미 없는 테스트를 수십 개 찍어내 유지보수 부담만 늘리기도 한다.
// AI가 만들 수 있는 의미 없는 테스트들
test('빈 문자열을 전달하면 빈 문자열을 반환한다', () => ...);
test('문자열 "a"를 전달하면 "a"를 반환한다', () => ...);
test('문자열 "ab"를 전달하면 "ab"를 반환한다', () => ...);
test('문자열 "abc"를 전달하면 "abc"를 반환한다', () => ...);
// 이런 패턴이 20개 이상 반복...테스트의 가치는 개수가 아니라 각 테스트가 검증하는 비즈니스 규칙의 고유성에서 나온다.
테스트 피라미드의 재구성
AI 시대에는 전통적 테스트 피라미드의 비율이 달라진다.
위 차트에서 첫 번째 막대는 전통적 비율, 두 번째 막대는 AI 시대 비율이다.
| 테스트 레벨 | 전통 비중 | AI 시대 비중 | 변화 이유 |
|---|---|---|---|
| 단위 테스트 | 40% | 20% | AI가 생성한 코드와 함께 작성 가능 |
| 통합 테스트 | 25% | 40% | AI가 모듈 간 연결을 틀리기 쉬움 |
| E2E 테스트 | 25% | 25% | 최종 사용자 관점은 여전히 중요 |
| 계약 테스트 | 10% | 15% | AI가 API 계약을 임의로 바꾸지 않았는지 확인 |
통합 테스트 비중이 높아지는 이유
AI는 함수나 모듈 하나는 잘 만들지만 모듈끼리 주고받는 지점에서 자주 틀린다. 인증 - 데이터 조회 - 응답 변환의 흐름에서 단계마다는 멀쩡한데 이어 붙이면 타입 불일치, 빠진 변환, 뒤바뀐 순서가 튀어나오는 식이다.
통합 테스트 작성 예시
AI에게 구현을 맡길 때 쓰는 통합 테스트는 이런 구조다.
// 통합 테스트: API 엔드포인트
describe('POST /api/orders', () => {
// 사전 조건 설정
beforeEach(async () => {
await seedTestData({
users: [testUser],
products: [testProduct],
});
});
// 정상 시나리오
test('인증된 사용자가 유효한 상품을 주문하면 성공', async () => {
const res = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${testToken}`)
.send({ productId: testProduct.id, quantity: 1 });
expect(res.status).toBe(201);
expect(res.body.order.status).toBe('PENDING');
// DB 상태 검증
const savedOrder = await db.order.findFirst({
where: { userId: testUser.id },
});
expect(savedOrder).toBeTruthy();
expect(savedOrder.productId).toBe(testProduct.id);
});
// 인증 검증
test('인증 없이 요청하면 401', async () => {
const res = await request(app)
.post('/api/orders')
.send({ productId: testProduct.id, quantity: 1 });
expect(res.status).toBe(401);
});
// 비즈니스 규칙 검증
test('재고가 부족하면 409 에러', async () => {
const res = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${testToken}`)
.send({ productId: testProduct.id, quantity: 9999 });
expect(res.status).toBe(409);
expect(res.body.error).toContain('재고 부족');
});
// 데이터 정합성 검증
test('주문 생성 시 재고가 차감된다', async () => {
const before = await db.product.findUnique({
where: { id: testProduct.id },
});
await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${testToken}`)
.send({ productId: testProduct.id, quantity: 2 });
const after = await db.product.findUnique({
where: { id: testProduct.id },
});
expect(after.stock).toBe(before.stock - 2);
});
});이 테스트를 AI에게 건네면 AI는 모든 시나리오를 통과하는 구현을 만들어야 한다. 자연어로 풀어 설명하는 것보다 훨씬 정확한 명세다.
AI 위임용 테스트에서 사람과 AI의 역할 분담
사람이 테스트를 설계하고 AI가 구현과 보일러플레이트를 맡는 구조가 대체로 잘 굴러간다.
| 사람의 역할 | AI의 역할 |
|---|---|
| 비즈니스 규칙 테스트 설계 | 테스트 코드 구현 |
| 엣지 케이스 정의 | 테스트 데이터 생성 |
| 실패 조건 명시 | 보일러플레이트 작성 |
| 통합 시나리오 설계 | 커버리지 보완 |
테스트 시나리오 작성의 구조
AI에게 구현을 맡길 때 시나리오를 어떻게 짜면 좋은지 보자.
## 기능: 쿠폰 적용
### 정상 시나리오
- 유효한 쿠폰 코드 입력 시 할인 적용
- 퍼센트 할인과 정액 할인 모두 지원
- 할인 후 최소 결제금액 0원
### 실패 시나리오
- 만료된 쿠폰: 에러 메시지와 함께 거부
- 이미 사용된 쿠폰: 중복 사용 방지
- 존재하지 않는 쿠폰: 명확한 에러
### 경계 조건
- 할인이 주문 금액보다 큰 경우: 0원 처리
- 동시에 같은 쿠폰 사용 시도: 하나만 성공
- 쿠폰 만료 시각 경계: KST 자정 기준
### 데이터 조건
- 쿠폰 테이블 스키마: code, type, value, expiresAt, usedAt
- 시간 계산: dayjs, KST 기준정상-실패-경계-데이터 순서로 시나리오를 짜면 놓치기 쉬운 엣지 케이스가 자연스럽게 따라 나온다.
테스트가 살아있는 문서가 되는 효과
테스트를 먼저 잘 써두면 그게 곧 살아 있는 기능 명세서가 되는 덤이 따라온다.
describe('주문 취소 정책', () => {
test('결제 완료 후 24시간 이내: 전액 환불', () => { /* ... */ });
test('결제 완료 후 24시간 초과: 환불 불가', () => { /* ... */ });
test('배송 시작 후: 반품 절차 안내', () => { /* ... */ });
test('이미 취소된 주문: 중복 취소 방지', () => { /* ... */ });
test('부분 환불 기간: 수수료 10% 제외', () => { /* ... */ });
});이 테스트 파일만 읽어도 주문 취소 정책의 비즈니스 규칙이 바로 잡힌다. 별도 기획 문서보다 믿을 만한 문서이기도 하다. 실행해서 검증되니까.
| 문서 유형 | 최신성 | 신뢰도 | 엣지 케이스 | 검증 |
|---|---|---|---|---|
| 기획 문서 | 구현 후 갱신 안 됨 | 낮음 | 대개 누락 | 수동 확인 |
| API 명세 | Swagger로 자동 가능 | 중간 | 일부 포함 | 실행 가능 |
| 테스트 코드 | 항상 최신 | 높음 | 자연스럽게 포함 | 자동 실행 |
기획 문서는 시간이 지나면 현실과 어긋나지만, 테스트 코드는 CI에서 매번 실행되니 코드와 자동으로 발맞춘다. 이 차이는 프로젝트가 커질수록 더 벌어진다.
다음 장 미리보기
테스팅 전략을 바꿨다면 다음은 디버깅 습관 리셋이다. console.log를 여기저기 심어 추적하던 방식 대신, AI에게 증상을 설명하고 가설-검증으로 디버깅하는 새로운 접근법을 다룬다.
참고 자료
참고 자료 안내
이 장의 관점과 프레임워크를 뒷받침하는 참고 자료입니다. 본문의 모든 주장이 아래 자료에서 직접 인용된 것은 아니며, 실무 경험과 커뮤니티 사례를 종합한 해석이 포함되어 있습니다.
- Fowler, M. (2012). "TestPyramid." https://martinfowler.com/bliki/TestPyramid.html