본문 바로가기

AI Agent

AI 에이전트 테스트 전략 완전 가이드 — 단위 테스트부터 통합 테스트, E2E까지

반응형

일반 소프트웨어는 같은 입력에 항상 같은 출력이 나옵니다. 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

 

반응형