AI 기반 테스트
AI로 테스트 코드를 생성하고 테스트 자동화하는 방법을 배웁니다. 단위 테스트, 통합 테스트, E2E 테스트를 AI로 만들어보세요.
개요
AI를 활용하면 테스트 코드를 더 빠르고 정확하게 작성할 수 있습니다. 이 가이드에서는 AI로 다양한 종류의 테스트를 생성하고 자동화하는 방법을 배웁니다.
- 단위 테스트 (Unit Test): 개별 함수/메서드 테스트
- 통합 테스트 (Integration Test): 여러 모듈 간의 상호작용 테스트
- E2E 테스트 (End-to-End): 사용자 관점의 전체 플로우 테스트
단위 테스트 생성
개별 함수나 메서드에 대한 단위 테스트를 AI로 생성할 수 있습니다.
테스트 생성 요청
다음 JavaScript 함수의 단위 테스트를 만들어줘:
```javascript
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
```
Jest를 사용하고, 정상적인 값과
에지 케이스를 모두 테스트해줘.
생성된 테스트 코드
const { add, subtract, multiply } = require('./calculator');
describe('Calculator', () => {
describe('add', () => {
test('두 양수의 합', () => {
expect(add(2, 3)).toBe(5);
});
test('음수 더하기', () => {
expect(add(-5, 3)).toBe(-2);
});
test('0 더하기', () => {
expect(add(0, 10)).toBe(10);
});
});
describe('subtract', () => {
test('두 양수의 차', () => {
expect(subtract(10, 3)).toBe(7);
});
});
describe('multiply', () => {
test('두 양수의 곱', () => {
expect(multiply(4, 5)).toBe(20);
});
test('0과 곱하기', () => {
expect(multiply(100, 0)).toBe(0);
});
});
});
통합 테스트 생성
여러 모듈이 함께 동작하는 것을 테스트합니다.
테스트 생성 요청
다음 Express.js API의 통합 테스트를 만들어줘:
- GET /api/users - 모든 사용자 조회
- GET /api/users/:id - 특정 사용자 조회
- POST /api/users - 사용자 생성
- DELETE /api/users/:id - 사용자 삭제
supertest를 사용하고, 각 엔드포인트를
테스트해줘.
통합 테스트는 실제 데이터베이스나 외부 API를 모의 객체(mock)로 대체하여 테스트합니다.
E2E 테스트 생성
사용자 관점에서 전체 애플리케이션 플로우를 테스트합니다.
Playwright 설치
# 설치
npm init playwright@latest
# 또는 기존 프로젝트에 추가
npm install -D @playwright/test
npx playwright install # 브라우저 다운로드
Playwright 실제 E2E 테스트 코드
import { test, expect, type Page } from '@playwright/test';
// 페이지 객체 헬퍼
async function login(page: Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel('이메일').fill(email);
await page.getByLabel('비밀번호').fill(password);
await page.getByRole('button', { name: '로그인' }).click();
await expect(page).toHaveURL('/dashboard');
}
test.describe('투두 관리 E2E 플로우', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'test@example.com', 'password123');
});
test('투두 추가 → 완료 표시 → 삭제 전체 플로우', async ({ page }) => {
// 1. 투두 추가
const todoText = `테스트 투두 ${Date.now()}`;
await page.getByPlaceholder('새 항목 추가...').fill(todoText);
await page.keyboard.press('Enter');
// 2. 투두가 목록에 나타나는지 확인
const todoItem = page.getByText(todoText);
await expect(todoItem).toBeVisible();
// 3. 완료 체크박스 클릭
await todoItem.locator('xpath=../..').getByRole('checkbox').click();
await expect(todoItem).toHaveClass(/completed/);
// 4. 삭제 버튼 클릭
await todoItem.locator('xpath=../..').getByRole('button', { name: '삭제' }).click();
await expect(todoItem).not.toBeVisible();
});
test('빈 입력으로 투두 추가 시 에러 표시', async ({ page }) => {
await page.getByRole('button', { name: '추가' }).click();
await expect(page.getByText('내용을 입력해주세요')).toBeVisible();
});
test('로그아웃 후 리다이렉트', async ({ page }) => {
await page.getByRole('button', { name: '로그아웃' }).click();
await expect(page).toHaveURL('/login');
});
});
// API 모킹 예시
test('API 오류 시 에러 메시지 표시', async ({ page }) => {
// 서버 응답을 모킹
await page.route('**/api/todos', route => {
route.fulfill({ status: 500, body: 'Internal Server Error' });
});
await page.goto('/dashboard');
await expect(page.getByText('데이터를 불러올 수 없습니다')).toBeVisible();
});
Playwright 설정 (playwright.config.ts)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
테스트 커버리지 측정
코드 커버리지를 측정하여 테스트 누락 영역을 파악합니다.
Jest 커버리지 설정
{
"scripts": {
"test": "jest",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch"
},
"jest": {
"coverageThreshold": {
"global": {
"branches": 70,
"functions": 80,
"lines": 80,
"statements": 80
}
},
"coverageReporters": ["text", "lcov", "html"],
"collectCoverageFrom": [
"src/**/*.{js,ts}",
"!src/**/*.d.ts",
"!src/main.ts"
]
}
}
# 커버리지 측정 + 리포트 생성
npm run test:coverage
# 출력 예시:
# -------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# -------|---------|----------|---------|---------|
# All | 82.5 | 75.0 | 85.0 | 82.5 |
# HTML 리포트 열기
open coverage/lcov-report/index.html
테스트 자동화
CI/CD 파이프라인에 테스트를 통합하여 자동화합니다.
GitHub Actions에서 테스트 자동화
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
- name: Run E2E tests
run: npm run test:e2e
E2E 테스트는 데이터베이스와 외부 서비스를 필요로 할 수 있으므로, CI 환경에서 적절히 설정해야 합니다.
테스트 생성 프롬프트 팁
더 나은 테스트를 생성하기 위한 프롬프트 작성 팁입니다.
- 코드 공유: 테스트할 함수의 전체 코드 제공
- 테스트 프레임워크 명시: Jest, Mocha, Pytest 등
- 에지 케이스 언급: null, undefined, 빈 값 등
- 기대 결과 명시: 어떤 결과가 나와야 하는지
- 기존 테스트 참조: 프로젝트의 테스트 스타일을 예시로 제공
좋은 프롬프트 예시
다음 validateEmail 함수의 Jest 단위 테스트를 만들어줘.
함수 코드:
function validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
요구사항:
1. 유효한 이메일 주소 테스트
2. 유효하지 않은 이메일 주소 테스트 (@ 없음, 도메인 없음)
3. 빈 문자열 테스트
4. null, undefined 입력 테스트
나쁜 프롬프트 예시와 개선
// 모호한 요청 → 결과 품질 낮음
"이 코드 테스트 만들어줘"
// 프레임워크 미지정 → 불일치한 결과
"이 함수 테스트해줘"
// 구체적인 요청 → 고품질 결과
"src/utils/dateFormatter.ts의 formatDate 함수에 대해
Jest + TypeScript 단위 테스트를 작성해줘.
테스트 항목:
1. ISO 8601 형식 입력 → 'YYYY년 MM월 DD일' 출력
2. Unix timestamp 입력 → 올바른 날짜 문자열
3. 유효하지 않은 날짜 → Error throw
4. 타임존 처리 (UTC, KST)
프로젝트 테스트 스타일 참고:
```typescript
describe('모듈명', () => {
it('should 동작 설명 when 조건', () => {
// Arrange → Act → Assert 패턴
});
});
```"
AI를 활용한 Mock/Stub 생성
외부 API, 데이터베이스, 파일 시스템 등 외부 의존성을 Mock으로 대체하는 것은 테스트 작성에서 가장 어려운 부분 중 하나입니다. AI를 활용하면 복잡한 Mock 설정을 빠르게 생성할 수 있습니다.
API Mock 생성 예시
import { jest } from '@jest/globals';
import { fetchUserProfile } from '../services/userService';
import * as api from '../utils/httpClient';
// httpClient 모듈 전체를 Mock으로 대체
jest.mock('../utils/httpClient');
const mockedApi = api as jest.Mocked<typeof api>;
describe('UserService', () => {
afterEach(() => {
jest.resetAllMocks();
});
test('정상적인 프로필 조회', async () => {
// Arrange: Mock 응답 설정
const mockUser = {
id: 1,
name: '홍길동',
email: 'hong@example.com',
role: 'admin'
};
mockedApi.get.mockResolvedValue({ data: mockUser });
// Act
const result = await fetchUserProfile(1);
// Assert
expect(result.name).toBe('홍길동');
expect(mockedApi.get).toHaveBeenCalledWith('/api/users/1');
expect(mockedApi.get).toHaveBeenCalledTimes(1);
});
test('네트워크 오류 시 재시도', async () => {
// 첫 번째 호출 실패, 두 번째 성공
mockedApi.get
.mockRejectedValueOnce(new Error('Network Error'))
.mockResolvedValueOnce({ data: { id: 1, name: '홍길동' } });
const result = await fetchUserProfile(1);
expect(result.name).toBe('홍길동');
expect(mockedApi.get).toHaveBeenCalledTimes(2);
});
test('404 응답 시 null 반환', async () => {
mockedApi.get.mockRejectedValue({
response: { status: 404 }
});
const result = await fetchUserProfile(999);
expect(result).toBeNull();
});
});
데이터베이스 Mock 예시
import pytest
from unittest.mock import MagicMock, patch
from services.order_service import create_order
class TestOrderService:
"""주문 서비스 테스트 (DB Mock 활용)"""
@patch("services.order_service.db")
def test_create_order_success(self, mock_db):
"""정상 주문 생성 테스트"""
# Arrange: DB Mock 설정
mock_db.products.find_one.return_value = {
"id": 1, "name": "키보드",
"price": 50000, "stock": 10
}
mock_db.orders.insert_one.return_value = MagicMock(
inserted_id="order-001"
)
# Act
result = create_order(product_id=1, quantity=2)
# Assert
assert result["order_id"] == "order-001"
assert result["total"] == 100000
mock_db.products.update_one.assert_called_once()
@patch("services.order_service.db")
def test_create_order_out_of_stock(self, mock_db):
"""재고 부족 시 에러 테스트"""
mock_db.products.find_one.return_value = {
"id": 1, "name": "키보드",
"price": 50000, "stock": 0
}
with pytest.raises(ValueError, match="재고 부족"):
create_order(product_id=1, quantity=2)
스냅샷 테스트와 AI
스냅샷 테스트는 컴포넌트의 출력이 의도치 않게 변경되는 것을 감지합니다. AI를 활용하면 초기 스냅샷 생성과 스냅샷 변경 분석을 자동화할 수 있습니다.
import { render } from '@testing-library/react';
import { UserCard } from '../components/UserCard';
describe('UserCard 스냅샷 테스트', () => {
test('기본 렌더링 스냅샷', () => {
const { container } = render(
<UserCard
name="홍길동"
email="hong@example.com"
role="admin"
/>
);
expect(container).toMatchSnapshot();
});
test('프로필 이미지 없을 때 스냅샷', () => {
const { container } = render(
<UserCard
name="김철수"
email="kim@example.com"
role="user"
avatarUrl={undefined}
/>
);
expect(container).toMatchSnapshot();
});
test('긴 이름 처리 스냅샷', () => {
const { container } = render(
<UserCard
name="매우 긴 이름을 가진 사용자의 프로필 카드"
email="longname@example.com"
role="user"
/>
);
expect(container).toMatchSnapshot();
});
});
AI 테스트 생성 워크플로우
AI를 활용한 테스트 생성의 전체 워크플로우를 정리합니다. 코드 분석부터 테스트 작성, 검증, 피드백까지의 반복 과정입니다.
AI 테스트 생성 워크플로우: 코드 분석 → 프롬프트 → 생성 → 검증 → 피드백 반복
- 맹목적 신뢰 금지: AI가 생성한 테스트가 항상 올바른 것은 아닙니다. 반드시 실행하고 결과를 확인하세요.
- 비즈니스 로직 검증: AI는 함수의 동작은 테스트하지만, 비즈니스 요구사항의 정확성까지 보장하지 않습니다.
- 테스트 독립성: 생성된 테스트 간 의존성이 없는지 확인하세요. 각 테스트는 독립적으로 실행 가능해야 합니다.
- 하드코딩된 값: AI가 예시로 넣은 상수값이 실제 환경과 맞는지 검토하세요.
테스트 피라미드
효과적인 테스트 전략을 위한 테스트 피라미드 구조입니다:
테스트 피라미드: 단위 테스트를 가장 많게, E2E를 가장 적게
| 테스트 유형 | 비율 | 실행 속도 | AI 활용 효과 | 적합한 프롬프트 전략 |
|---|---|---|---|---|
| 단위 테스트 | 70% | 매우 빠름 (ms) | 높음 (반복 패턴 자동화) | 함수 코드 + 에지 케이스 목록 제공 |
| 통합 테스트 | 20% | 보통 (초) | 중간 (Mock 설정 자동화) | 모듈 간 인터페이스 + 시나리오 제공 |
| E2E 테스트 | 10% | 느림 (분) | 높음 (UI 상호작용 자동화) | 사용자 시나리오 + 페이지 구조 제공 |
속성 기반 테스트 (Property-Based Testing)
속성 기반 테스트는 구체적인 입출력 대신 "항상 참이어야 하는 속성"을 정의하고, 랜덤 입력으로 속성 위반을 탐지합니다. AI에게 속성을 설명하면 효과적인 속성 기반 테스트를 생성할 수 있습니다.
from hypothesis import given, strategies as st
from hypothesis import assume
from utils.math_utils import safe_divide, clamp
@given(a=st.integers(), b=st.integers())
def test_safe_divide_never_raises(a, b):
"""safe_divide는 어떤 입력에도 예외를 발생시키지 않아야 함"""
result = safe_divide(a, b)
assert result is not None # 항상 값을 반환
@given(a=st.integers(), b=st.integers(min_value=1))
def test_safe_divide_correct(a, b):
"""0이 아닌 수로 나눌 때 결과가 올바라야 함"""
result = safe_divide(a, b)
assert abs(result * b - a) < 1e-9
@given(
value=st.floats(allow_nan=False, allow_infinity=False),
min_val=st.floats(allow_nan=False, allow_infinity=False),
max_val=st.floats(allow_nan=False, allow_infinity=False)
)
def test_clamp_always_in_range(value, min_val, max_val):
"""clamp 결과는 항상 [min, max] 범위 내에 있어야 함"""
assume(min_val <= max_val)
result = clamp(value, min_val, max_val)
assert min_val <= result <= max_val
다음 단계
테스트 자동화에 대해 더 자세히 배워보세요!
핵심 정리
- 단위 테스트: 개별 함수/메서드를 테스트, 가장 빠르고 저렴
- 통합 테스트: 여러 모듈 간 상호작용 테스트
- E2E 테스트: 사용자 관점의 전체 플로우 테스트
- 테스트 피라미드: 단위 테스트를 가장 많이 작성
- CI/CD 통합: 푸시 시 자동으로 테스트 실행