테스팅 전략의 전환
구현 후 테스트에서 테스트가 설계를 이끄는 AI 시대 테스팅으로
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