AI Server 테스트 전략
AI 서버의 유닛 테스트, 통합 테스트, E2E 테스트 전략 가이드
📚 목차
1. 테스트 개요
1.1. 테스트 목표
| 목표 |
설명 |
| 기능 정확성 |
API가 명세대로 동작하는지 검증 |
| 응답 형식 |
LLM 출력이 예상 JSON 스키마와 일치하는지 검증 |
| 성능 기준 |
응답 시간, TTFT가 목표치 내인지 검증 |
| 안정성 |
에러 처리, 타임아웃, 재시도 로직 검증 |
| 회귀 방지 |
코드 변경 시 기존 기능이 깨지지 않는지 검증 |
1.2. 테스트 대상 API (9개)
| # |
API |
테스트 우선순위 |
복잡도 |
| 1 |
/ai/ocr/extract |
🟡 Medium |
비동기 + VLM |
| 2 |
/ai/file/embed |
🟢 Low |
단순 임베딩 |
| 3 |
/ai/analyze |
🔴 High |
스트리밍 + RAG |
| 4 |
/ai/interview/question |
🔴 High |
DB 히스토리 + RAG |
| 5 |
/ai/interview/save |
🟢 Low |
단순 저장 |
| 6 |
/ai/interview/report |
🔴 High |
스트리밍 + 복잡한 분석 |
| 7 |
/ai/chat |
🔴 High |
스트리밍 + RAG + Tool Calling |
| 8 |
/ai/calendar/parse |
🟡 Medium |
JSON 파싱 |
| 9 |
/ai/masking/draft |
🟡 Medium |
비동기 + VLM |
2. 테스트 유형 및 범위
2.1. 테스트 피라미드
▲
/│\
/ │ \
/ │ \ E2E 테스트 (10%)
/ │ \ - 실제 LLM API 호출
/ │ \ - 전체 시나리오 검증
/─────┼─────\
/ │ \
/ │ \ 통합 테스트 (30%)
/ │ \ - API 엔드포인트 검증
/ │ \ - 서비스 간 연동
/──────────┼──────────\
/ │ \
/ │ \ 유닛 테스트 (60%)
/ │ \ - 개별 함수/클래스
/ │ \ - Mock 활용
/───────────────┴───────────────\
2.2. 테스트 유형별 특징
| 유형 |
범위 |
LLM 호출 |
실행 시간 |
목적 |
| 유닛 |
함수/클래스 |
❌ Mock |
< 1초 |
로직 검증 |
| 통합 |
API 엔드포인트 |
❌ Mock 또는 ✅ 실제 |
1~10초 |
연동 검증 |
| E2E |
전체 시나리오 |
✅ 실제 |
10~60초 |
시나리오 검증 |
3. 테스트 디렉터리 구조
ai_server/
├── app/
│ ├── api/
│ ├── services/
│ ├── chains/
│ └── ...
│
├── tests/
│ ├── conftest.py # pytest fixtures (공통)
│ ├── __init__.py
│ │
│ ├── unit/ # 유닛 테스트
│ │ ├── __init__.py
│ │ ├── test_prompt_templates.py
│ │ ├── test_output_parsers.py
│ │ ├── test_embedding_service.py
│ │ ├── test_calendar_parser.py
│ │ ├── test_masking_coords.py
│ │ └── test_interview_logic.py
│ │
│ ├── integration/ # 통합 테스트
│ │ ├── __init__.py
│ │ ├── test_ocr_api.py
│ │ ├── test_embed_api.py
│ │ ├── test_analyze_api.py
│ │ ├── test_interview_api.py
│ │ ├── test_chat_api.py
│ │ ├── test_calendar_api.py
│ │ └── test_masking_api.py
│ │
│ ├── e2e/ # E2E 테스트
│ │ ├── __init__.py
│ │ ├── test_resume_flow.py # 이력서 업로드 → 분석 전체 흐름
│ │ ├── test_interview_flow.py # 면접 시작 → 질문 → 리포트
│ │ └── test_chat_scenario.py # 대화 + Tool Calling
│ │
│ ├── fixtures/ # 테스트 데이터
│ │ ├── sample_resume.txt
│ │ ├── sample_job_posting.txt
│ │ ├── sample_image.png
│ │ └── expected_responses.json
│ │
│ └── mocks/ # Mock 클래스
│ ├── mock_llm.py
│ ├── mock_vectordb.py
│ └── mock_external_api.py
│
├── pytest.ini # pytest 설정
└── requirements-test.txt # 테스트 의존성
4. 유닛 테스트
4.1. 테스트 대상
| 대상 |
파일 |
테스트 내용 |
| 프롬프트 템플릿 |
test_prompt_templates.py |
변수 치환, 포맷 검증 |
| 출력 파서 |
test_output_parsers.py |
JSON 파싱, 스키마 검증 |
| 임베딩 처리 |
test_embedding_service.py |
청킹, 벡터 변환 로직 |
| 캘린더 파서 |
test_calendar_parser.py |
날짜 추출, 일정 구조화 |
| 마스킹 좌표 |
test_masking_coords.py |
좌표 변환, 영역 계산 |
| 면접 로직 |
test_interview_logic.py |
질문 수 체크, 세션 관리 |
4.2. 예시 코드
프롬프트 템플릿 테스트
# tests/unit/test_prompt_templates.py
import pytest
from app.prompts.analyze_prompts import create_analyze_prompt
class TestAnalyzePrompt:
"""분석 프롬프트 템플릿 테스트"""
def test_create_analyze_prompt_with_valid_input(self):
"""정상 입력으로 프롬프트 생성"""
resume = "3년차 백엔드 개발자, Python, FastAPI 경험"
posting = "백엔드 개발자 채용, Python 필수"
prompt = create_analyze_prompt(resume, posting)
assert resume in prompt
assert posting in prompt
assert "분석" in prompt or "analyze" in prompt.lower()
def test_create_analyze_prompt_with_empty_resume(self):
"""빈 이력서는 에러 발생"""
with pytest.raises(ValueError, match="이력서"):
create_analyze_prompt("", "채용공고")
def test_prompt_token_limit(self):
"""프롬프트가 토큰 한계를 초과하지 않는지 확인"""
long_resume = "A" * 50000 # 매우 긴 이력서
posting = "채용공고"
prompt = create_analyze_prompt(long_resume, posting)
# 토큰 수 추정 (대략 4자 = 1토큰)
estimated_tokens = len(prompt) / 4
assert estimated_tokens < 30000 # 컨텍스트 한계 내
출력 파서 테스트
# tests/unit/test_output_parsers.py
import pytest
from app.services.output_parsers import parse_analyze_response
class TestAnalyzeParser:
"""분석 결과 파서 테스트"""
def test_parse_valid_response(self):
"""정상 JSON 응답 파싱"""
llm_output = '''
{
"resume_analysis": {
"strengths": ["Python 경험 3년"],
"weaknesses": ["클라우드 경험 부족"],
"suggestions": ["AWS 학습 추천"]
},
"matching": {
"score": 85,
"grade": "A"
}
}
'''
result = parse_analyze_response(llm_output)
assert result["matching"]["score"] == 85
assert result["matching"]["grade"] == "A"
assert len(result["resume_analysis"]["strengths"]) > 0
def test_parse_malformed_json(self):
"""잘못된 JSON은 에러 또는 기본값 반환"""
malformed = "이건 JSON이 아닙니다"
with pytest.raises(ValueError):
parse_analyze_response(malformed)
def test_parse_missing_required_fields(self):
"""필수 필드 누락 시 에러"""
incomplete = '{"resume_analysis": {}}'
with pytest.raises(KeyError):
parse_analyze_response(incomplete)
면접 로직 테스트
# tests/unit/test_interview_logic.py
import pytest
from app.services.interview_service import InterviewSession
class TestInterviewSession:
"""면접 세션 로직 테스트"""
def test_session_creation(self):
"""세션 생성"""
session = InterviewSession(
room_id="room_001",
interview_type="technical"
)
assert session.session_id is not None
assert session.question_count == 0
assert session.status == "in_progress"
def test_max_questions_limit(self):
"""최대 질문 수(5개) 도달 시 자동 종료"""
session = InterviewSession("room_001", "technical")
for i in range(5):
session.add_qa(f"질문 {i+1}", f"답변 {i+1}")
assert session.question_count == 5
assert session.should_auto_end() == True
def test_manual_end(self):
"""수동 종료"""
session = InterviewSession("room_001", "personality")
session.add_qa("질문 1", "답변 1")
session.end(ended_by="manual")
assert session.status == "completed"
assert session.ended_by == "manual"
5. 통합 테스트
5.1. 테스트 대상
| API |
테스트 파일 |
검증 항목 |
/ai/analyze |
test_analyze_api.py |
스트리밍 응답, JSON 스키마 |
/ai/interview/* |
test_interview_api.py |
질문 생성, 저장, 리포트 연동 |
/ai/chat |
test_chat_api.py |
RAG 검색, Tool Calling |
/ai/calendar/parse |
test_calendar_api.py |
일정 추출 정확도 |
5.2. 예시 코드
분석 API 통합 테스트
# tests/integration/test_analyze_api.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
class TestAnalyzeAPI:
"""분석 API 통합 테스트"""
@pytest.fixture
def sample_request(self):
return {
"resume_id": "resume_123",
"posting_id": "posting_456",
"resume_text": "3년차 백엔드 개발자...",
"posting_text": "백엔드 개발자 채용..."
}
def test_analyze_returns_streaming_response(self, sample_request):
"""스트리밍 응답 형식 검증"""
response = client.post(
"/ai/analyze",
json=sample_request,
headers={"Accept": "text/event-stream"}
)
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/event-stream")
def test_analyze_response_schema(self, sample_request, mock_llm):
"""응답 JSON 스키마 검증"""
response = client.post("/ai/analyze", json=sample_request)
data = response.json()
# 필수 필드 검증
assert "resume_analysis" in data
assert "posting_analysis" in data
assert "matching" in data
# 매칭 스키마 검증
matching = data["matching"]
assert 0 <= matching["score"] <= 100
assert matching["grade"] in ["S", "A", "B", "C", "D"]
def test_analyze_with_missing_resume(self):
"""이력서 누락 시 400 에러"""
response = client.post(
"/ai/analyze",
json={"posting_text": "채용공고"}
)
assert response.status_code == 400
면접 API 통합 테스트
# tests/integration/test_interview_api.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
class TestInterviewAPI:
"""면접 API 통합 테스트"""
def test_interview_full_flow(self, mock_llm, mock_db):
"""면접 전체 흐름: 질문 생성 → 저장 → 리포트"""
# 1. 첫 질문 생성
question_response = client.post(
"/ai/interview/question",
json={
"room_id": "room_001",
"session_id": "session_123",
"interview_type": "technical",
"resume_text": "이력서...",
"posting_text": "채용공고..."
}
)
assert question_response.status_code == 200
question_data = question_response.json()
assert "question" in question_data
assert question_data["question_number"] == 1
# 2. Q&A 저장
save_response = client.post(
"/ai/interview/save",
json={
"room_id": "room_001",
"session_id": "session_123",
"question_id": question_data["question_id"],
"question": question_data["question"],
"answer": "사용자 답변...",
"is_followup": False,
"question_number": 1
}
)
assert save_response.status_code == 200
# 3. 리포트 생성 (5개 질문 완료 가정)
report_response = client.post(
"/ai/interview/report",
json={
"room_id": "room_001",
"session_id": "session_123",
"interview_type": "technical",
"ended_by": "manual"
}
)
assert report_response.status_code == 200
report_data = report_response.json()
assert "evaluations" in report_data
assert "report" in report_data
채팅 API Tool Calling 테스트
# tests/integration/test_chat_api.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
class TestChatAPI:
"""채팅 API 통합 테스트"""
def test_chat_with_calendar_tool(self, mock_llm_with_tool_call):
"""캘린더 Tool Calling 검증"""
response = client.post(
"/ai/chat",
json={
"room_id": "room_001",
"user_id": "user_456",
"message": "이번 주 일정 알려줘",
"history": []
}
)
data = response.json()
# Tool 호출 응답 형식 검증
assert data["tool_used"] is not None
assert data["tool_used"]["tool"] == "get_schedule"
assert "start_date" in data["tool_used"]["params"]
def test_chat_rag_response(self, mock_llm, mock_vectordb):
"""RAG 기반 대화 응답"""
response = client.post(
"/ai/chat",
json={
"room_id": "room_001",
"user_id": "user_456",
"message": "내 이력서 강점이 뭐야?",
"history": []
}
)
data = response.json()
assert data["success"] == True
assert data["response"] is not None
assert data["tool_used"] is None # RAG는 tool 아님
6. E2E 테스트
6.1. 테스트 시나리오
| 시나리오 |
흐름 |
검증 항목 |
| 이력서 분석 |
파일 업로드 → OCR → 임베딩 → 분석 |
전체 파이프라인 |
| 모의 면접 |
시작 → 질문 5개 → 저장 → 리포트 |
세션 관리, 일관성 |
| 대화 + 일정 추가 |
"내일 카카오 면접 추가해줘" → Tool → 저장 |
Tool Calling 연동 |
6.2. 예시 코드
# tests/e2e/test_resume_flow.py
import pytest
import time
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
class TestResumeAnalysisFlow:
"""이력서 분석 E2E 테스트 (실제 LLM 호출)"""
@pytest.mark.e2e
@pytest.mark.slow
def test_full_resume_analysis_flow(self):
"""이력서 업로드 → OCR → 임베딩 → 분석 전체 흐름"""
# 1. OCR 요청 (비동기)
ocr_response = client.post(
"/ai/ocr/extract",
json={
"file_url": "https://s3.../test_resume.pdf",
"file_type": "pdf"
}
)
assert ocr_response.status_code == 200
task_id = ocr_response.json()["task_id"]
# 2. OCR 완료 대기 (폴링)
for _ in range(30): # 최대 30초 대기
status_response = client.get(f"/ai/task/{task_id}")
status = status_response.json()["status"]
if status == "completed":
break
time.sleep(1)
assert status == "completed"
extracted_text = status_response.json()["result"]["extracted_text"]
# 3. 임베딩 저장
embed_response = client.post(
"/ai/file/embed",
json={
"type": "resume",
"id": "resume_e2e_test",
"user_id": "user_e2e",
"text": extracted_text
}
)
assert embed_response.status_code == 200
# 4. 분석 요청
analyze_response = client.post(
"/ai/analyze",
json={
"resume_id": "resume_e2e_test",
"posting_id": "posting_e2e",
"resume_text": extracted_text,
"posting_text": "백엔드 개발자 채용..."
}
)
assert analyze_response.status_code == 200
# 5. 응답 검증
result = analyze_response.json()
assert "resume_analysis" in result
assert "matching" in result
assert 0 <= result["matching"]["score"] <= 100
7. LLM/VLM 테스트 전략
7.1. Mock vs 실제 호출
| 상황 |
전략 |
이유 |
| 유닛 테스트 |
✅ Mock |
빠른 실행, 결정적 결과 |
| 통합 테스트 (CI) |
✅ Mock |
비용 절감, 안정성 |
| 통합 테스트 (로컬) |
⚠️ 선택적 실제 호출 |
실제 동작 확인 필요 시 |
| E2E 테스트 |
✅ 실제 호출 |
실제 시나리오 검증 |
7.2. LLM Mock 구현
# tests/mocks/mock_llm.py
from unittest.mock import MagicMock, AsyncMock
class MockLLM:
"""LLM API Mock 클래스"""
def __init__(self, response_type: str = "analyze"):
self.response_type = response_type
self.call_count = 0
async def generate(self, prompt: str) -> str:
"""Mock LLM 응답 생성"""
self.call_count += 1
if self.response_type == "analyze":
return '''
{
"resume_analysis": {
"strengths": ["Python 경험 풍부"],
"weaknesses": ["클라우드 경험 부족"],
"suggestions": ["AWS 학습 추천"]
},
"matching": {"score": 85, "grade": "A"}
}
'''
elif self.response_type == "interview_question":
return '''
{
"question": "Python의 GIL에 대해 설명해주세요.",
"is_followup": false
}
'''
elif self.response_type == "tool_call":
return '''
{
"tool": "get_schedule",
"params": {"start_date": "2026-01-06", "end_date": "2026-01-12"}
}
'''
return '{"message": "Mock response"}'
# conftest.py에서 fixture로 등록
@pytest.fixture
def mock_llm():
"""LLM Mock fixture"""
from app.services import llm_service
original = llm_service.llm
llm_service.llm = MockLLM()
yield llm_service.llm
llm_service.llm = original
7.3. 응답 품질 검증 (E2E 전용)
# tests/e2e/test_response_quality.py
import pytest
from app.services.quality_evaluator import evaluate_response
class TestResponseQuality:
"""LLM 응답 품질 검증 (실제 호출)"""
@pytest.mark.e2e
def test_analyze_response_quality(self, real_llm):
"""분석 응답 품질 검증"""
result = real_llm.analyze(
resume="3년차 백엔드 개발자...",
posting="백엔드 개발자 채용..."
)
# 품질 평가
quality = evaluate_response(result)
assert quality["completeness"] >= 0.8 # 필수 필드 80% 이상
assert quality["relevance"] >= 0.7 # 관련성 70% 이상
assert quality["format_valid"] == True # JSON 형식 유효
@pytest.mark.e2e
def test_interview_question_quality(self, real_llm):
"""면접 질문 품질 검증"""
question = real_llm.generate_question(
interview_type="technical",
resume="Python 백엔드 개발자..."
)
# 질문 품질 체크
assert len(question) >= 10 # 최소 길이
assert "?" in question # 질문 형태
assert "Python" in question or "백엔드" in question # 맥락 관련성
8. 테스트 도구 및 환경
8.1. 필수 도구
| 도구 |
용도 |
설치 |
| pytest |
테스트 프레임워크 |
pip install pytest |
| pytest-asyncio |
비동기 테스트 |
pip install pytest-asyncio |
| pytest-cov |
코드 커버리지 |
pip install pytest-cov |
| httpx |
비동기 HTTP 클라이언트 |
pip install httpx |
| respx |
HTTP Mock |
pip install respx |
| faker |
테스트 데이터 생성 |
pip install faker |
8.2. 테스트 의존성 파일
# requirements-test.txt
pytest>=7.0.0
pytest-asyncio>=0.21.0
pytest-cov>=4.0.0
pytest-timeout>=2.1.0
pytest-xdist>=3.0.0 # 병렬 실행
httpx>=0.24.0
respx>=0.20.0
faker>=18.0.0
8.3. pytest 설정
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
markers =
unit: 유닛 테스트
integration: 통합 테스트
e2e: E2E 테스트 (실제 LLM 호출)
slow: 느린 테스트
filterwarnings =
ignore::DeprecationWarning
# 타임아웃 설정
timeout = 60
timeout_method = thread
# 커버리지 설정
addopts = --cov=app --cov-report=html --cov-report=term-missing
8.4. 테스트 실행 명령어
# 전체 테스트 실행
pytest
# 유닛 테스트만 실행
pytest -m unit
# 통합 테스트만 실행
pytest -m integration
# E2E 테스트 실행 (느림, 비용 발생)
pytest -m e2e
# 특정 파일 테스트
pytest tests/integration/test_analyze_api.py
# 병렬 실행 (4개 프로세스)
pytest -n 4
# 커버리지 리포트 생성
pytest --cov=app --cov-report=html
# 실패한 테스트만 재실행
pytest --lf
9. CI/CD 연동
9.1. GitHub Actions 워크플로우
# .github/workflows/ai-server-test.yml
name: AI Server Tests
on:
push:
branches: [main, develop]
paths:
- 'ai_server/**'
pull_request:
branches: [main, develop]
paths:
- 'ai_server/**'
jobs:
unit-test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
cd ai_server
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run unit tests
run: |
cd ai_server
pytest -m unit --cov=app --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./ai_server/coverage.xml
integration-test:
name: Integration Tests
runs-on: ubuntu-latest
needs: unit-test
services:
chromadb:
image: chromadb/chroma:latest
ports:
- 8000:8000
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
cd ai_server
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run integration tests (with mocks)
env:
CHROMA_HOST: localhost
CHROMA_PORT: 8000
USE_MOCK_LLM: true # LLM Mock 사용
run: |
cd ai_server
pytest -m integration
e2e-test:
name: E2E Tests (Manual)
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' # 수동 트리거만
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
cd ai_server
pip install -r requirements.txt
pip install -r requirements-test.txt
- name: Run E2E tests
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
USE_MOCK_LLM: false # 실제 LLM 사용
run: |
cd ai_server
pytest -m e2e --timeout=120
9.2. 테스트 단계별 실행 정책
| 단계 |
트리거 |
LLM 호출 |
예상 시간 |
| 유닛 테스트 |
모든 PR/Push |
❌ Mock |
~1분 |
| 통합 테스트 |
모든 PR/Push |
❌ Mock |
~3분 |
| E2E 테스트 |
수동 / 주간 스케줄 |
✅ 실제 |
~10분 |
10. 테스트 로드맵
10.1. 단계별 구현 계획
┌─────────────────────────────────────────────────────────────────────────────┐
│ 테스트 로드맵 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: MVP (2주) │
│ ├── pytest 환경 구축 │
│ ├── 핵심 API 유닛 테스트 (analyze, interview, chat) │
│ ├── LLM Mock 구현 │
│ └── 기본 CI 연동 (GitHub Actions) │
│ │
│ Phase 2: 1차 배포 (3주) │
│ ├── 전체 API 유닛 테스트 완료 │
│ ├── 통합 테스트 구현 (API 엔드포인트) │
│ ├── 테스트 커버리지 60% 달성 │
│ └── VectorDB Mock 구현 │
│ │
│ Phase 3: 2차 배포 (4주) │
│ ├── E2E 테스트 시나리오 구현 │
│ ├── 응답 품질 검증 테스트 │
│ ├── 성능 테스트 (부하 테스트) │
│ └── 테스트 커버리지 80% 달성 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
10.2. 커버리지 목표
| 단계 |
목표 커버리지 |
측정 범위 |
| MVP |
40% |
핵심 서비스 로직 |
| 1차 배포 |
60% |
API 레이어 포함 |
| 2차 배포 |
80% |
전체 코드베이스 |
10.3. 테스트 우선순위
| 우선순위 |
API |
이유 |
| ⭐⭐⭐ |
/ai/analyze |
핵심 기능, 복잡도 높음 |
| ⭐⭐⭐ |
/ai/interview/* |
세션 관리, 상태 유지 |
| ⭐⭐⭐ |
/ai/chat |
Tool Calling, RAG 복합 |
| ⭐⭐ |
/ai/calendar/parse |
JSON 파싱 정확도 |
| ⭐⭐ |
/ai/masking/draft |
좌표 추출 정확도 |
| ⭐ |
/ai/ocr/extract |
VLM 의존, Mock 어려움 |
| ⭐ |
/ai/file/embed |
단순 로직 |
참고 자료