일반 소프트웨어는 같은 입력에 항상 같은 출력이 나옵니다. AI 에이전트는 그렇지 않습니다. 테스트 전략 자체가 달라야 합니다.
[핵심 요약]
→ 문제: AI 에이전트는 비결정적 → 기존 단위 테스트로는 불충분
→ 해결: 레이어별 테스트 전략 (툴 → 에이전트 로직 → 통합 → E2E)
→ 핵심 도구: pytest + unittest.mock, LangSmith, Pytest-asyncio
→ 평가 방법: LLM-as-Judge, 골든셋 비교, 행동 기반 검증
→ CI/CD: 에이전트 테스트를 파이프라인에 자동화하는 법
→ 원칙: 완벽한 재현보다 "허용 가능한 범위" 검증이 핵심
왜 기존 테스트가 안 통하나
소프트웨어 테스트의 기본 전제는 결정론적 동작입니다. 같은 입력 → 항상 같은 출력. AI 에이전트는 이 전제를 깹니다.
일반 함수 테스트:
def add(a, b):
return a + b
assert add(1, 2) == 3 # 항상 통과
AI 에이전트 테스트 (틀린 접근):
result = agent.run("파이썬 버그 고쳐줘")
assert result == "fixed the null check on line 42" # 100% 실패
→ 응답이 매번 다름
→ 옳은 답이 여러 개
→ 정확한 문자열 매칭 불가능
올바른 접근:
result = agent.run("파이썬 버그 고쳐줘")
assert "null" in result.lower() or "none" in result.lower() # 핵심 개념 포함 여부
assert len(result) > 50 # 최소한의 답변 길이
assert evaluate_quality(result) >= 0.8 # LLM-as-Judge 품질 점수
[AI 에이전트 테스트의 3가지 원칙]
→ 완벽한 재현 포기: 비결정적 특성 인정, 허용 범위로 검증
→ 행동 기반 검증: "무엇을 말했나"보다 "무엇을 했나" 검증
→ 레이어별 분리: 툴/LLM/오케스트레이터를 독립 테스트
테스트 레이어 구조
[AI 에이전트 테스트 피라미드]
┌─────────────┐
│ E2E 테스트 │ ← 느림, 비쌈, 핵심 시나리오만
│ (5~10%) │
─┼─────────────┼─
┌─┴─────────────┴─┐
│ 통합 테스트 │ ← 에이전트 + 실제 툴
│ (20~30%) │
─┼─────────────────┼─
┌─┴─────────────────┴─┐
│ 단위 테스트 │ ← 빠름, 저렴, LLM 목킹
│ (60~70%) │
└─────────────────────┘
핵심: 아래로 갈수록 많이, 위로 갈수록 핵심만
실전 1 — 툴(Tool) 단위 테스트
에이전트가 호출하는 툴 자체를 먼저 독립적으로 테스트합니다. 이 레이어는 기존 소프트웨어 테스트와 동일합니다. LLM 호출 없이 빠르게 실행 가능합니다.
import pytest
from unittest.mock import patch, MagicMock
from agent.tools import (
search_database,
create_github_issue,
send_slack_message,
execute_code
)
# ===== 데이터베이스 조회 툴 테스트 =====
class TestSearchDatabase:
def test_returns_results_for_valid_query(self):
"""정상 쿼리 시 결과 반환"""
results = search_database("SELECT * FROM users LIMIT 5")
assert isinstance(results, list)
assert len(results) <= 5
def test_raises_on_dangerous_query(self):
"""위험한 쿼리 차단"""
with pytest.raises(ValueError, match="위험한 쿼리"):
search_database("DROP TABLE users")
def test_returns_empty_list_for_no_results(self):
"""결과 없을 때 빈 리스트 반환 (None 아님)"""
results = search_database("SELECT * FROM users WHERE id = -1")
assert results == []
def test_handles_connection_error(self):
"""DB 연결 실패 시 명확한 에러"""
with patch("agent.tools.get_db_connection") as mock_conn:
mock_conn.side_effect = ConnectionError("DB 연결 실패")
with pytest.raises(ConnectionError):
search_database("SELECT 1")
# ===== GitHub 이슈 생성 툴 테스트 =====
class TestCreateGithubIssue:
@patch("agent.tools.github_client")
def test_creates_issue_with_required_fields(self, mock_github):
"""필수 필드로 이슈 생성 성공"""
mock_github.create_issue.return_value = {"id": 123, "url": "https://..."}
result = create_github_issue(
title="버그: 로그인 실패",
body="재현 방법: ...",
labels=["bug"]
)
assert result["id"] == 123
mock_github.create_issue.assert_called_once()
def test_raises_on_empty_title(self):
"""빈 제목 거부"""
with pytest.raises(ValueError, match="제목"):
create_github_issue(title="", body="내용")
@patch("agent.tools.github_client")
def test_retries_on_rate_limit(self, mock_github):
"""Rate Limit 시 재시도"""
mock_github.create_issue.side_effect = [
Exception("rate limit exceeded"),
{"id": 456, "url": "https://..."}
]
result = create_github_issue(title="테스트", body="내용")
assert result["id"] == 456
assert mock_github.create_issue.call_count == 2
[툴 단위 테스트 체크리스트]
→ 정상 케이스: 올바른 입력 → 올바른 출력
→ 엣지 케이스: 빈 입력, 최대 길이, 특수문자
→ 오류 케이스: 네트워크 실패, 권한 없음, 타임아웃
→ 보안 케이스: SQL 인젝션, 경로 탐색, 악성 입력
→ LLM 호출 없음 → 빠르고 저렴하게 실행 가능
실전 2 — 에이전트 로직 단위 테스트 (LLM 목킹)
LLM 호출을 목킹해서 에이전트의 의사결정 로직만 독립 테스트합니다.
import pytest
from unittest.mock import patch, AsyncMock
import json
class TestAgentDecisionLogic:
@pytest.fixture
def mock_llm_response(self):
"""LLM 응답 목킹 헬퍼"""
def make_response(content: str, tool_calls: list = None):
response = MagicMock()
response.content = content
response.tool_calls = tool_calls or []
return response
return make_response
@patch("agent.core.call_llm")
async def test_agent_calls_correct_tool_for_db_query(
self, mock_llm, mock_llm_response
):
"""DB 관련 질문에 search_database 툴 호출 여부 확인"""
# LLM이 search_database 툴을 호출하도록 목킹
mock_llm.return_value = mock_llm_response(
content="",
tool_calls=[{
"name": "search_database",
"arguments": {"query": "SELECT * FROM orders WHERE status='pending'"}
}]
)
agent = CodingAgent()
result = await agent.run("미처리 주문 목록 조회해줘")
# LLM이 올바른 툴을 선택했는지 검증
mock_llm.assert_called_once()
call_args = mock_llm.call_args
# 시스템 프롬프트에 DB 툴 정의가 포함됐는지
assert "search_database" in str(call_args)
@patch("agent.core.call_llm")
async def test_agent_stops_on_task_completion(self, mock_llm):
"""작업 완료 신호 시 루프 종료"""
# 첫 번째 응답: 툴 호출
# 두 번째 응답: 완료 선언 (툴 호출 없음)
mock_llm.side_effect = [
MagicMock(tool_calls=[{"name": "search_database", "arguments": {}}]),
MagicMock(tool_calls=[], content="조회 완료. 결과: 5개 주문 발견.")
]
agent = CodingAgent()
result = await agent.run("미처리 주문 있어?")
# 정확히 2번 LLM 호출 (무한 루프 방지 확인)
assert mock_llm.call_count == 2
assert "완료" in result or "발견" in result
@patch("agent.core.call_llm")
async def test_agent_respects_max_iterations(self, mock_llm):
"""최대 반복 횟수 초과 시 강제 종료"""
# LLM이 계속 툴 호출만 하는 상황 (무한 루프)
mock_llm.return_value = MagicMock(
tool_calls=[{"name": "search_database", "arguments": {}}]
)
agent = CodingAgent(max_iterations=5)
with pytest.raises(MaxIterationsExceeded):
await agent.run("끝나지 않는 작업")
# 최대 5번만 호출됐는지 확인
assert mock_llm.call_count <= 5
[LLM 목킹 포인트]
→ call_llm 함수 단위로 목킹 (전체 에이전트 아님)
→ 다양한 LLM 응답 시나리오 재현 가능
→ API 비용 0원, 수 밀리초 실행
→ 에이전트 의사결정 로직만 순수하게 검증
실전 3 — 통합 테스트 (실제 툴 + 목킹된 LLM)
LLM은 목킹하되 실제 외부 시스템(DB, GitHub API 등)을 사용합니다.
import pytest
from agent import CodingAgent
from unittest.mock import patch
# pytest fixtures
@pytest.fixture
def test_database():
"""테스트용 인메모리 DB"""
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("""
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
status TEXT,
amount REAL,
created_at TEXT
)
""")
conn.executemany(
"INSERT INTO orders VALUES (?, ?, ?, ?)",
[
(1, "pending", 50000, "2026-04-28"),
(2, "completed", 30000, "2026-04-27"),
(3, "pending", 80000, "2026-04-28"),
]
)
conn.commit()
yield conn
conn.close()
class TestAgentIntegration:
@patch("agent.core.call_llm")
async def test_full_db_query_flow(self, mock_llm, test_database):
"""에이전트 → 실제 DB 조회 → 결과 해석 전체 플로우"""
# LLM 응답 시나리오 정의
mock_llm.side_effect = [
# 1. 첫 번째 LLM 호출: DB 조회 툴 선택
MagicMock(tool_calls=[{
"name": "search_database",
"arguments": {"query": "SELECT * FROM orders WHERE status='pending'"}
}]),
# 2. 툴 결과 받고 최종 답변 생성
MagicMock(
tool_calls=[],
content="미처리 주문 2건입니다. 총 130,000원 (주문 #1: 50,000원, #3: 80,000원)"
)
]
agent = CodingAgent(db_connection=test_database)
result = await agent.run("미처리 주문 현황 알려줘")
# 결과 검증 (정확한 문자열 대신 핵심 정보 포함 여부)
assert "2" in result or "두" in result # 2건
assert "130,000" in result or "130000" in result # 총액
assert "pending" not in result.lower() # 기술 용어 노출 없음
@patch("agent.core.call_llm")
async def test_error_handling_when_db_fails(self, mock_llm, test_database):
"""DB 실패 시 에이전트가 적절히 처리하는지"""
mock_llm.side_effect = [
MagicMock(tool_calls=[{
"name": "search_database",
"arguments": {"query": "SELECT * FROM nonexistent_table"}
}]),
# DB 오류 후 LLM이 에러 처리
MagicMock(
tool_calls=[],
content="죄송합니다. 데이터 조회 중 오류가 발생했습니다."
)
]
agent = CodingAgent(db_connection=test_database)
result = await agent.run("없는 테이블 조회해줘")
# 에러가 사용자에게 친절하게 전달됐는지
assert "오류" in result or "실패" in result or "죄송" in result
# 기술적 에러 메시지 노출 안 됐는지
assert "OperationalError" not in result
assert "sqlite3" not in result
실전 4 — LLM-as-Judge (품질 자동 평가)
비결정적 LLM 출력의 품질을 다른 LLM이 자동으로 평가합니다.
import anthropic
from dataclasses import dataclass
@dataclass
class EvaluationResult:
score: float # 0.0 ~ 1.0
passed: bool
reason: str
details: dict
def llm_as_judge(
task: str,
agent_response: str,
criteria: list[str],
threshold: float = 0.7
) -> EvaluationResult:
"""
Claude가 에이전트 응답 품질을 자동 평가
Args:
task: 에이전트에게 주어진 작업
agent_response: 에이전트의 응답
criteria: 평가 기준 목록
threshold: 합격 기준 점수
"""
client = anthropic.Anthropic()
criteria_text = "\n".join(f"- {c}" for c in criteria)
evaluation_prompt = f"""다음 AI 에이전트의 응답을 평가해주세요.
[작업]
{task}
[에이전트 응답]
{agent_response}
[평가 기준]
{criteria_text}
각 기준을 0~1 사이 점수로 평가하고, 전체 평균 점수와 합격 여부(0.7 이상)를 JSON으로 반환해주세요.
반드시 아래 형식으로만 응답하세요:
{{
"criteria_scores": {{
"기준명": 점수,
...
}},
"average_score": 전체평균,
"passed": true/false,
"reason": "평가 이유 한 줄"
}}"""
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
messages=[{"role": "user", "content": evaluation_prompt}]
)
import json
eval_data = json.loads(response.content[0].text)
return EvaluationResult(
score=eval_data["average_score"],
passed=eval_data["passed"],
reason=eval_data["reason"],
details=eval_data["criteria_scores"]
)
# 테스트에서 사용
class TestAgentQuality:
async def test_code_review_quality(self):
"""코드 리뷰 응답 품질 자동 평가"""
agent = CodingAgent()
response = await agent.run("""
다음 코드 리뷰해줘:
def divide(a, b):
return a / b
""")
result = llm_as_judge(
task="파이썬 코드 리뷰",
agent_response=response,
criteria=[
"ZeroDivisionError 위험성을 언급했는가",
"구체적인 수정 방법을 제시했는가",
"코드 예시를 포함했는가",
"한국어로 응답했는가"
],
threshold=0.7
)
assert result.passed, f"품질 기준 미달 ({result.score:.2f}): {result.reason}"
assert result.score >= 0.7
[LLM-as-Judge 포인트]
→ 비결정적 출력을 자동으로 검증하는 핵심 패턴
→ 평가 기준을 명확히 정의할수록 신뢰도 높아짐
→ 평가 모델은 테스트 대상 모델과 다른 것 권장 (Claude로 GPT 평가 등)
→ 비용: 평가당 $0.001~0.01 수준
→ 주의: LLM-as-Judge도 완벽하지 않음 → 중요 케이스는 사람 검토 병행
실전 5 — 골든셋 테스트
검증된 입출력 쌍을 고정해두고 회귀 테스트로 활용합니다.
import json
import pytest
from pathlib import Path
# golden_set.json 예시
GOLDEN_SET = [
{
"id": "GS-001",
"input": "파이썬에서 리스트 중복 제거하는 가장 효율적인 방법",
"expected_contains": ["set", "dict.fromkeys", "O(n)"],
"expected_not_contains": ["for loop", "O(n²)"],
"min_score": 0.8
},
{
"id": "GS-002",
"input": "FastAPI에서 JWT 인증 구현해줘",
"expected_contains": ["import", "def", "jwt", "token"],
"expected_not_contains": ["session", "cookie"],
"min_score": 0.75
},
]
class TestGoldenSet:
@pytest.mark.parametrize("case", GOLDEN_SET)
async def test_golden_case(self, case):
"""골든셋 기반 회귀 테스트"""
agent = CodingAgent()
response = await agent.run(case["input"])
response_lower = response.lower()
# 필수 포함 요소 확인
for keyword in case["expected_contains"]:
assert keyword.lower() in response_lower, \
f"[{case['id']}] '{keyword}' 누락됨"
# 금지 요소 확인
for keyword in case["expected_not_contains"]:
assert keyword.lower() not in response_lower, \
f"[{case['id']}] '{keyword}' 포함됨 (금지 키워드)"
# 품질 점수 확인
eval_result = llm_as_judge(
task=case["input"],
agent_response=response,
criteria=["정확성", "코드 예시 포함", "한국어 응답"],
threshold=case["min_score"]
)
assert eval_result.score >= case["min_score"], \
f"[{case['id']}] 품질 미달 ({eval_result.score:.2f}): {eval_result.reason}"
실전 6 — CI/CD 파이프라인 통합
# .github/workflows/agent-tests.yml
name: Agent Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Tool Unit Tests
run: |
pip install pytest pytest-asyncio
# LLM 목킹 → API 키 불필요, 비용 0원
pytest tests/unit/ -v --timeout=30
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- name: Run Integration Tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# LLM 목킹 + 실제 DB/API
pytest tests/integration/ -v --timeout=120
golden-set-tests:
runs-on: ubuntu-latest
needs: integration-tests
# main 브랜치 push 시에만 실행 (비용 절감)
if: github.ref == 'refs/heads/main'
steps:
- name: Run Golden Set Tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
pytest tests/golden/ -v --timeout=300
[CI/CD 비용 최적화 전략]
→ 단위 테스트: 모든 PR에 실행 (LLM 목킹 → 비용 0원)
→ 통합 테스트: PR 머지 시 실행 (LLM 부분 목킹 → 최소 비용)
→ 골든셋/E2E: main 브랜치 push 시만 실행 (실제 LLM → 비용 발생)
→ 예상 월 비용: 소규모 팀 기준 $10~50 (테스트 빈도에 따라)
마무리
✅ 이 테스트 전략 써야 할 때
→ 프로덕션에 AI 에이전트를 배포할 모든 팀
→ 에이전트 품질이 배포마다 달라지는 걸 막고 싶을 때
→ LLM 업데이트 후 회귀 테스트가 필요할 때
→ 팀 규모 확장으로 에이전트 코드 관리가 복잡해질 때
❌ 과한 경우
→ 프로토타입/실험 단계 (골든셋 없이 빠르게 반복하는 것이 나음)
→ 단순 Q&A 챗봇 (에이전트 복잡도 낮으면 툴 테스트만으로 충분)
[테스트 커버리지 목표]
→ 툴 단위 테스트: 90%+ (기존 소프트웨어와 동일)
→ 에이전트 로직: 70%+ (주요 분기 커버)
→ 통합 테스트: 핵심 플로우 5~10개
→ 골든셋: 20~50개 케이스
→ E2E: 프로덕션 시나리오 3~5개
관련 글:
https://cell-devlog.tistory.com/26
AI 에이전트 성능을 어떻게 측정하나 — Evals와 평가 방법론 완전 정리
AI 에이전트를 만들고 나면 이런 질문이 생겨요."이 에이전트가 잘 동작하는 건지 어떻게 알지? 그냥 써보는 것 말고 제대로 측정하는 방법이 있나?"일반 소프트웨어는 테스트가 간단해요. 같은
cell-devlog.tistory.com
https://cell-devlog.tistory.com/103
AI 에이전트 프로덕션 실패 7가지 패턴
데모에선 완벽했어요. 프로덕션에 올렸더니 망가졌어요.AI 에이전트는 일반 소프트웨어와 다르게 실패해요.일반 소프트웨어 실패:→ 500 에러→ 타임아웃→ 명확한 스택 트레이스AI 에이전트 실
cell-devlog.tistory.com
https://cell-devlog.tistory.com/90
AI 에이전트 옵저버빌리티 완전 가이드 — 에이전트가 뭘 하는지 추적하는 법
AI 에이전트를 프로덕션에 배포하면 이런 일이 생겨요.새벽 3시 알람:"월간 LLM 비용 $2,000 초과"원인 파악 시도:- 로그 확인 → "에러 없음"- API 응답 확인 → "200 OK"- 에이전트 출력 확인 → "정상처
cell-devlog.tistory.com
'AI Agent' 카테고리의 다른 글
| AI 에이전트 롤백 전략 완전 가이드 — 에이전트가 망쳤을 때 복구하는 법 (0) | 2026.04.28 |
|---|---|
| AI 에이전트 상태 관리 완전 가이드 — 장기 실행 에이전트에서 상태를 잃지 않는 법 (0) | 2026.04.28 |
| MCP 9700만 설치 — Linux Foundation 오픈 거버넌스 채택, AI 에이전트 표준 인프라가 됐습니다 (0) | 2026.04.28 |
| Fabric MCP 서버 완전 가이드 — Claude Code에 240개 AI 패턴과 개발자 지식베이스 연결하기 (0) | 2026.04.27 |
| Strands Agents 완전 가이드 2편 — 실전 튜토리얼 (0) | 2026.04.23 |