Field Log · Entry

RAG Agent 테스트: Fake·Contract·Record/Replay 설계 (9/10)

순수 unit test와 fake contract HTTP transport record replay live smoke offline evaluation으로 구성한 RAG Agent 테스트 피라미드

오늘의 결론

  • RAG Agent test의 기본 경로는 실제 LLM이 아니라 deterministic fake입니다. Clock·ID·random·network를 port로 주입해야 같은 입력에서 같은 trajectory를 재현할 수 있습니다.
  • Fake가 그럴듯하게 동작하는지만 검사하지 않습니다. Fake adapter와 실제 HTTP adapter에 같은 contract suite를 통과시켜 interface drift를 막습니다.
  • Network failure는 function mock보다 HTTP transport 경계에서 재현합니다. Timeout, 429, malformed JSON, 잘린 SSE, cancellation처럼 parser·retry·cleanup을 함께 검증합니다.
  • Record/replay fixture는 raw traffic 보관소가 아닙니다. Authorization·cookie·PII·prompt·검색 문서를 allowlist 방식으로 제거하고 schema version과 provenance를 남깁니다.
  • Exact answer string보다 불변식을 검사합니다. Citation이 실제 evidence를 가리키는지, terminal이 하나인지, budget과 authorization을 지켰는지가 핵심입니다.

앞 글에서는 bounded SSE stream과 disconnect·resume protocol을 만들었습니다. 이번에는 81~88편의 경계를 빠르고 재현 가능하게 검증하는 test pyramid를 구성합니다.

순수 로직에서 fake contract와 HTTP transport, record replay, live smoke, offline evaluation으로 올라가는 RAG Agent 테스트 피라미드

이 글의 대상과 학습 시간

  • 대상: LLM·retrieval API 때문에 test가 느리거나 flaky한 Python 개발자
  • 선수 지식: pytest, async Python, port/adapter 구조, 82~88편의 protocol
  • 빠르게 읽기: 약 15분
  • Fake·fault suite까지 따라 하기: 약 70분
  • 산출물: deterministic test harness, shared contract suite, sanitized replay fixture

1. “실제 모델로 물어봤더니 맞았다”는 회귀 test가 아니다

Live LLM test 하나에 모든 기대를 걸면 실패 원인을 분리하기 어렵습니다.

  • Model snapshot이나 sampling이 바뀜
  • Retrieval index가 갱신됨
  • Provider rate limit·regional outage가 발생
  • Prompt version이 달라짐
  • Clock·UUID·retry jitter 때문에 trace가 달라짐
  • Network 비용과 latency 때문에 PR마다 실행하지 못함

이 test가 실패했을 때 application bug인지 upstream 변화인지 알 수 없습니다. Production RAG Agent는 서로 다른 질문을 서로 다른 층에서 검증해야 합니다.

질문외부 network실행 빈도
Pure unitContext·citation·state transition이 맞는가없음모든 commit
Fake componentRuntime이 scripted trajectory를 지키는가없음모든 commit
Contract모든 adapter가 같은 의미를 제공하는가기본 없음모든 PR
Transport integration실제 HTTP serialization·parser·retry가 맞는가Mock transport모든 PR
Record/replay알려진 provider wire shape를 다시 처리하는가없음모든 PR
Live smokeCredential·endpoint·현재 provider가 연결되는가있음제한적
Offline eval검색·근거·답변 품질 gate를 넘는가선택merge/release

위로 갈수록 비싸고 느리며 실패 원인이 많아집니다. 아래층을 넓게 만들고 live test는 작게 유지합니다.

2. Test 대상은 prose가 아니라 contract다

다음 assertion은 쉽게 깨집니다.

assert answer == "서울은 대한민국의 수도입니다."

같은 의미의 문장 변화도 실패합니다. 반대로 그 문장이 잘못된 evidence에서 나왔는지는 확인하지 못합니다.

RAG Agent에서 더 중요한 불변식은 다음과 같습니다.

  • Unauthorized document가 retrieval result와 prompt에 없음
  • Context token budget을 초과하지 않음
  • 모든 citation의 evidence ID와 source span이 존재함
  • Unsupported claim에서는 답변을 보류하거나 한계를 표시함
  • Tool call argument가 schema·policy validation을 통과함
  • Retryable error만 budget 안에서 재시도함
  • Event sequence가 단조 증가하고 terminal이 정확히 하나임
  • 같은 idempotency key와 request hash가 같은 run을 반환함
  • Run deadline 이후 새 external call을 시작하지 않음

Semantic quality는 별도 eval dataset으로 측정하되 software correctness와 섞지 않습니다.

3. Determinism을 dependency로 주입한다

Application 코드가 datetime.now(), uuid.uuid4(), random.random(), asyncio.sleep()을 직접 부르면 test가 wall clock과 운에 의존합니다.

from collections.abc import Awaitable
from datetime import datetime, timedelta, timezone
from typing import Protocol


class Clock(Protocol):
    def now(self) -> datetime: ...
    def monotonic(self) -> float: ...
    async def sleep(self, seconds: float) -> None: ...


class IdFactory(Protocol):
    def new(self, prefix: str) -> str: ...


class RandomSource(Protocol):
    def uniform(self, low: float, high: float) -> float: ...


class FakeClock:
    def __init__(self) -> None:
        self._wall = datetime(2026, 7, 16, tzinfo=timezone.utc)
        self._mono = 100.0

    def now(self) -> datetime:
        return self._wall

    def monotonic(self) -> float:
        return self._mono

    async def sleep(self, seconds: float) -> None:
        if seconds < 0:
            raise ValueError("sleep must be non-negative")
        self.advance(seconds)

    def advance(self, seconds: float) -> None:
        self._mono += seconds
        self._wall += timedelta(seconds=seconds)


class SequentialIds:
    def __init__(self) -> None:
        self._next = 1

    def new(self, prefix: str) -> str:
        value = f"{prefix}_test_{self._next:04d}"
        self._next += 1
        return value

Fake clock은 retry backoff 단위 test를 빠르게 만들지만, event loop scheduling과 실제 socket timeout까지 증명하지는 않습니다. 실제 clock을 쓰는 소수 integration test도 남깁니다.

Global patch보다 constructor injection

monkeypatch는 environment나 legacy global을 임시 변경할 때 유용합니다. 그러나 직접 통제하는 application dependency는 constructor나 function argument로 받는 편이 의존성과 lifetime이 더 분명합니다.

runtime = AgentRuntime(
    llm=FakeLlm(...),
    retrieval=FakeRetrieval(...),
    clock=FakeClock(),
    ids=SequentialIds(),
    random=FixedRandom(0.5),
)

4. Fake·Stub·Mock·Spy를 구분한다

용어 자체보다 test가 무엇을 보장하는지 분명해야 합니다.

  • Stub: 정해 둔 값이나 오류를 반환
  • Fake: 단순하지만 동작하는 구현. 예: memory event store
  • Spy: 받은 call을 기록해 나중에 검사
  • Mock: 기대한 interaction이 발생했는지 자체적으로 판정

한 객체가 여러 역할을 할 수 있지만, production semantics를 흉내 내지 못하는 fake가 test를 통과시키는 위험을 항상 봐야 합니다.

Scripted LLM fake

from collections import deque
from dataclasses import dataclass


@dataclass(frozen=True)
class Emit:
    event: LlmEvent


@dataclass(frozen=True)
class Raise:
    error: Exception


ScriptStep = Emit | Raise


class ScriptedLlm:
    def __init__(self, scripts: list[list[ScriptStep]]) -> None:
        self._scripts = deque(deque(script) for script in scripts)
        self.requests: list[LlmRequest] = []

    async def stream(self, request: LlmRequest):
        self.requests.append(request)
        if not self._scripts:
            raise AssertionError("unexpected LLM call")

        script = self._scripts.popleft()
        while script:
            match script.popleft():
                case Emit(event):
                    yield event
                case Raise(error):
                    raise error

    def assert_consumed(self) -> None:
        if self._scripts:
            raise AssertionError(f"unused LLM scripts: {len(self._scripts)}")

Script는 “이 prompt 문자열이면 이 답”보다 call 순서와 protocol event를 표현합니다. 예상하지 않은 추가 LLM call과 남은 script도 test 실패로 만들어 runaway loop를 잡습니다.

Scripted retrieval fake

class FakeRetrieval:
    def __init__(self, results: list[Evidence]) -> None:
        self._results = results
        self.calls: list[SearchRequest] = []

    async def search(self, request: SearchRequest) -> list[Evidence]:
        self.calls.append(request)
        return [item.model_copy(deep=True) for item in self._results]

Deep copy를 반환해 application이 fixture를 실수로 mutate해도 다음 test에 누출되지 않게 합니다.

5. 하나의 vertical slice를 fake로 검증한다

import pytest


@pytest.mark.asyncio
async def test_answer_uses_only_authorized_evidence_and_terminates():
    retrieval = FakeRetrieval([
        evidence("ev_a", acl=["team-a"], text="허용된 근거"),
    ])
    llm = ScriptedLlm([[
        Emit(text_delta("허용된 ")),
        Emit(text_delta("근거입니다.")),
        Emit(citation("ev_a", answer_start=0, answer_end=7)),
        Emit(message_done(input_tokens=120, output_tokens=8)),
    ]])
    runtime = make_runtime(retrieval=retrieval, llm=llm)

    events = [
        event async for event in runtime.stream(
            query="근거를 알려줘",
            principal=principal(groups={"team-a"}),
        )
    ]

    assert events[-1].type == "done"
    assert sum(event.terminal for event in events) == 1
    assert citation_ids(events) == {"ev_a"}
    assert all(call.principal_groups == {"team-a"} for call in retrieval.calls)
    assert "허용된 근거" in llm.requests[0].rendered_context
    llm.assert_consumed()

이 test에는 실제 HTTP도 vector DB도 없습니다. 하지만 authorization filter가 request에 전달되고, evidence가 prompt에 들어가며, citation과 terminal이 연결되는 application contract는 검증합니다.

6. Fake와 real adapter에 같은 contract suite를 적용한다

Fake가 production adapter와 다른 의미를 제공하면 unit test가 거짓 안전감을 줍니다. Contract suite는 구현체가 지켜야 할 공통 의미를 함수로 만듭니다.

from collections.abc import Callable

LlmFactory = Callable[[list[ProviderFixture]], LlmPort]


async def assert_llm_contract(factory: LlmFactory) -> None:
    client = factory([fixture_text("안녕"), fixture_done(3, 2)])
    events = [event async for event in client.stream(minimal_request())]

    assert [event.type for event in events] == ["text_delta", "message_done"]
    assert join_text(events) == "안녕"
    assert events[-1].usage.output_tokens == 2


async def assert_error_contract(factory: LlmFactory) -> None:
    client = factory([fixture_http_error(429, retry_after="2")])

    with pytest.raises(RateLimited) as caught:
        _ = [event async for event in client.stream(minimal_request())]

    assert caught.value.retryable is True
    assert caught.value.retry_after_seconds == 2.0


@pytest.mark.asyncio
async def test_scripted_fake_contract():
    await assert_llm_contract(scripted_llm_factory)
    await assert_error_contract(scripted_llm_factory)


@pytest.mark.asyncio
async def test_http_adapter_contract():
    await assert_llm_contract(mock_transport_llm_factory)
    await assert_error_contract(mock_transport_llm_factory)

Contract는 implementation detail이 아니라 consumer가 의존하는 의미를 검사합니다.

  • Empty delta를 무시하는가
  • Usage가 terminal에서 한 번 확정되는가
  • 429가 typed RateLimited로 변환되는가
  • Malformed terminal이 ProtocolError인가
  • Cancellation을 retryable failure로 바꾸지 않는가
  • Provider ID가 trace metadata로 보존되는가

실제 provider live contract는 소수 case만 별도 schedule로 실행합니다.

7. HTTPX MockTransport로 wire boundary를 검증한다

LLM method 자체를 patch하면 request serialization, header, status mapping, stream parser를 건너뜁니다. HTTPX는 request를 받아 미리 정한 response를 반환하는 MockTransport를 제공합니다.

import json

import httpx
import pytest


@pytest.mark.asyncio
async def test_adapter_serializes_request_and_maps_429():
    seen: list[httpx.Request] = []

    def handler(request: httpx.Request) -> httpx.Response:
        seen.append(request)
        return httpx.Response(
            status_code=429,
            headers={
                "retry-after": "2",
                "x-request-id": "provider_req_test",
            },
            json={"error": {"code": "rate_limit"}},
        )

    transport = httpx.MockTransport(handler)
    async with httpx.AsyncClient(
        transport=transport,
        base_url="https://provider.invalid",
    ) as http:
        adapter = ProviderLlmAdapter(http=http, api_key="test-secret")
        with pytest.raises(RateLimited) as caught:
            _ = [event async for event in adapter.stream(minimal_request())]

    assert len(seen) == 1
    assert seen[0].method == "POST"
    assert seen[0].url.path == "/v1/messages"
    assert json.loads(seen[0].content)["stream"] is True
    assert caught.value.provider_request_id == "provider_req_test"

Test failure output에 authorization header를 출력하지 않습니다. Credential 내용보다 header가 존재하고 adapter가 redaction policy를 지키는지 검사합니다.

잘린 stream 재현

class ChunkStream(httpx.AsyncByteStream):
    def __init__(self, chunks: list[bytes], *, fail_after: int | None = None) -> None:
        self._chunks = chunks
        self._fail_after = fail_after
        self.closed = False

    async def __aiter__(self):
        for index, chunk in enumerate(self._chunks):
            if self._fail_after == index:
                raise httpx.ReadError("scripted truncated stream")
            yield chunk

    async def aclose(self) -> None:
        self.closed = True


def truncated_handler(request: httpx.Request) -> httpx.Response:
    return httpx.Response(
        200,
        headers={"content-type": "text/event-stream"},
        stream=ChunkStream([
            b'event: content_block_delta\n',
            b'data: {"delta":{"text":"partial"}}\n\n',
            b'event: message_stop\ndata: {}\n\n',
        ], fail_after=2),
    )

이 fixture로 다음을 함께 확인합니다.

  • Partial text 뒤 network error가 성공 done으로 바뀌지 않음
  • Response stream이 aclose()
  • Retry policy가 이미 노출한 delta를 무작정 중복 전송하지 않음
  • Public error와 provider exception detail이 분리됨

8. Fault matrix를 표로 관리한다

Happy path fixture만 늘리면 복원력 코드는 실제로 실행되지 않습니다.

Fault주입 위치기대 결과
Connect timeoutHTTP transportretry budget 내 backoff
Read timeout before deltastream open/read정책에 따라 retry 가능
Read error after deltastream parserterminal error, 중복 text 금지
HTTP 429 + Retry-Afterresponse mappingserver hint와 deadline 반영
HTTP 500response mappingbounded retry 또는 breaker count
Malformed JSONdecodertyped protocol error
Unknown event typeparserforward-compatible ignore 또는 명시 거부
Missing terminalparser/runtimeprotocol error
Duplicate terminalruntimeinvariant violation
Tool timeouttool adaptercancellation·receipt 기록
Client disconnectSSE consumercancel 또는 unsubscribe policy
DB version conflictpersistencereload·conflict response
Stale ACLretrieval adapterresult reject·audit metric

각 row에는 owner test file과 최소 한 개 assertion을 연결합니다. “Retry test 있음”이 아니라 어떤 side effect가 중복되지 않는지도 봅니다.

9. Retry test는 clock과 call count를 함께 본다

@pytest.mark.asyncio
async def test_retry_stops_at_shared_deadline():
    clock = FakeClock()
    llm = ScriptedLlm([
        [Raise(UpstreamTimeout("first"))],
        [Raise(UpstreamTimeout("second"))],
        [Emit(text_delta("must not happen"))],
    ])
    client = ResilientLlm(
        inner=llm,
        clock=clock,
        random=FixedRandom(0.0),
        retry=RetryPolicy(max_attempts=5, base_seconds=1.0),
    )

    with pytest.raises(DeadlineExceeded):
        _ = [
            event async for event in client.stream(
                minimal_request(deadline_monotonic=101.5)
            )
        ]

    assert len(llm.requests) == 2
    assert clock.monotonic() <= 101.5

단순히 exception type만 보지 않고 attempt 수, elapsed budget, request ID, side effect count를 함께 확인합니다.

10. Record/replay는 redacted contract fixture다

실제 provider response shape를 한 번 capture해 offline에서 replay하면 SDK·parser regression을 찾는 데 유용합니다. 그러나 raw HTTP dump를 repository에 넣으면 다음이 유출될 수 있습니다.

  • Authorization·cookie·signed URL
  • User query와 session identifier
  • Retrieved document와 개인·기밀 정보
  • Prompt의 system instruction
  • Provider request ID와 account metadata

따라서 cassette는 allowlist schema로 다시 만듭니다.

{
  "schema_version": 2,
  "case_id": "text_then_tool_then_done",
  "recorded_at": "2026-07-16T00:00:00Z",
  "provider_family": "example-messages-api",
  "request": {
    "method": "POST",
    "path": "/v1/messages",
    "body_fixture": "minimal_tool_request_v1"
  },
  "response": {
    "status": 200,
    "headers": {"content-type": "text/event-stream"},
    "chunks_base64": ["ZXZlbnQ6Li4u"]
  },
  "redaction": {
    "policy_version": 3,
    "verified_by": "manual-review"
  }
}

Raw request body 대신 synthetic fixture name을 저장하고, response도 실제 사용자 내용이 없는 synthetic account에서 기록합니다.

Redaction은 denylist보다 allowlist

SAFE_RESPONSE_HEADERS = {
    "content-type",
    "retry-after",
    "x-request-id",  # 값은 synthetic token으로 치환
}


def sanitize_headers(headers: httpx.Headers) -> dict[str, str]:
    safe: dict[str, str] = {}
    for name in SAFE_RESPONSE_HEADERS:
        if name in headers:
            safe[name] = (
                "provider_req_redacted"
                if name == "x-request-id"
                else headers[name]
            )
    return safe

api-key, authorization 같은 알려진 이름만 지우는 denylist는 새 custom header를 놓칠 수 있습니다. 보관 목적에 필요한 field만 선택합니다.

Cassette gate

  • Schema validation 통과
  • Secret scanner 통과
  • Email·phone·token pattern scan
  • 최대 file size 제한
  • Manual review metadata
  • 만료일·provider API version 기록
  • Binary/base64 decode 후에도 scan

Capture mode는 developer가 명시적으로 켤 때만 동작하고 CI 기본값에서는 network를 차단합니다.

11. Replay가 증명하지 못하는 것

Record/replay가 성공해도 다음은 보장되지 않습니다.

  • 현재 provider가 같은 schema를 반환함
  • Credential와 endpoint가 유효함
  • Rate limit·latency가 production과 같음
  • Model 품질이 유지됨
  • HTTP/2, proxy, TLS, cancellation timing이 같음

Replay는 과거에 관찰한 wire fixture를 현재 parser가 처리한다는 회귀 test입니다. 최신 compatibility를 확인하는 sparse live smoke와 역할이 다릅니다.

12. Live test는 작고 명시적으로 격리한다

import os

import pytest


live = pytest.mark.skipif(
    os.getenv("RUN_LIVE_LLM_TESTS") != "1",
    reason="live provider tests are opt-in",
)


@live
@pytest.mark.asyncio
async def test_provider_live_minimal_stream():
    adapter = build_live_adapter_from_environment()
    events = [event async for event in adapter.stream(live_smoke_request())]

    assert events[-1].type == "message_done"
    assert sum(event.type == "message_done" for event in events) == 1
    assert join_text(events).strip()

Live test 원칙:

  • Synthetic prompt와 public document만 사용
  • 낮은 token·cost ceiling
  • 별도 low-privilege credential
  • Retry를 적게 두고 outage를 product bug와 구분
  • PR 필수 gate가 아니라 nightly 또는 release candidate gate
  • 실패 시 sanitized request ID와 provider status page 연결

13. Property와 stateful test로 순서 조합을 탐색한다

Idempotency·event replay·bounded state machine은 단일 example보다 action sequence에서 깨집니다.

from hypothesis import given, strategies as st


@given(st.lists(st.sampled_from(["delta", "citation"]), max_size=30))
def test_terminal_event_is_final(prefix_types: list[str]):
    events = [make_event(kind, index + 1) for index, kind in enumerate(prefix_types)]
    events.append(make_event("done", len(events) + 1))

    validate_stream(events)
    assert events[-1].terminal
    assert all(not event.terminal for event in events[:-1])

Hypothesis rule-based state machine은 다음 action 조합을 생성할 수 있습니다.

  • create(key, hash)
  • duplicate(key, same_hash)
  • conflict(key, different_hash)
  • append(expected_version)
  • worker_crash()
  • lease_expire()
  • takeover()
  • complete(owner_token)

항상 유지할 invariant:

  • 한 key·hash의 completed response는 하나
  • Session sequence에 gap·duplicate 없음
  • Old owner가 takeover 뒤 complete하지 못함
  • Terminal 뒤 append 불가

Shrinking은 실패를 만든 긴 sequence를 작은 재현 case로 줄이는 데 유용합니다. 단순한 함수라면 stateful test보다 일반 @given이 더 읽기 쉬울 수 있습니다.

14. Offline eval과 software test를 연결하되 섞지 않는다

Software test가 모두 통과해도 retrieval quality는 나쁠 수 있습니다. 반대로 model 문장이 조금 달라져도 software contract는 정상일 수 있습니다.

Local golden dataset 예시

{
  "case_id": "policy_retention_001",
  "query": "삭제 요청 후 백업 보존 기간은?",
  "principal_fixture": "privacy-team",
  "expected_evidence_ids": ["policy-v7:p12:s3"],
  "forbidden_evidence_ids": ["policy-v6:p9:s1"],
  "must_abstain": false,
  "required_claims": ["백업은 별도 만료 절차를 따른다"]
}

Release gate를 분리합니다.

  • Retrieval gate: Recall@k, stale/forbidden result rate
  • Grounding gate: Citation validity, claim support
  • Behavior gate: Abstention, tool policy violation
  • System gate: p95 latency, timeout/error budget, cost ceiling

Exact prose snapshot은 UI formatting처럼 정말 동일해야 하는 부분에만 씁니다.

15. CI에서 network를 기본 거부한다

Test가 실수로 production endpoint를 호출하면 flaky와 비용뿐 아니라 data leak 위험도 생깁니다.

방어선:

  1. Domain port를 fake로 주입
  2. HTTP client는 test transport만 사용
  3. Credential environment variable 제거
  4. 예상하지 않은 host request는 즉시 실패
  5. Live marker는 별도 job·별도 secret scope에서만 허용
def deny_unexpected_network(request: httpx.Request) -> httpx.Response:
    raise AssertionError(f"unexpected network host: {request.url.host}")


@pytest.fixture
def offline_http() -> httpx.AsyncClient:
    return httpx.AsyncClient(
        transport=httpx.MockTransport(deny_unexpected_network),
        base_url="https://offline.invalid",
    )

Fixture teardown에서 client를 닫도록 실제 코드는 async fixture 또는 context manager로 감쌉니다.

16. Flaky test를 retry로 숨기지 않는다

Test retry plugin으로 초록색을 만들면 race가 남습니다. Flaky test에는 최소 다음 metadata를 붙입니다.

  • 최초 발견 commit과 owner
  • 실패 seed·cassette·trace ID
  • 재현 command
  • Clock/network/random 의존성
  • Quarantine 만료일

Retry 횟수보다 왜 nondeterministic한지를 고칩니다. 동시성 test는 arbitrary sleep(0.1) 대신 barrier, event, queue depth처럼 관찰 가능한 synchronization point를 사용합니다.

17. 자주 실패하는 테스트 전략

1. 모든 것을 MagicMock으로 만든다

Async iterator·context manager·stream cleanup contract가 사라집니다. Protocol을 구현한 작은 fake와 transport fixture를 사용합니다.

2. Fake만 test하고 real adapter에 같은 contract를 적용하지 않는다

Fake가 production과 다른 event type이나 error semantics를 가질 수 있습니다.

3. 실제 답변 전문을 snapshot으로 고정한다

무해한 문장 변화에 깨지고 citation·ACL 오류는 놓칩니다. 의미 불변식과 구조를 검사합니다.

4. Raw cassette를 commit한다

Header를 지워도 body·chunk·base64 안에 prompt와 문서가 남을 수 있습니다. Synthetic capture와 allowlist redaction을 사용합니다.

5. Replay를 현재 provider compatibility로 해석한다

Replay는 과거 fixture test입니다. 소수 live smoke를 별도로 둡니다.

6. Timeout test에서 실제로 30초 기다린다

Clock을 주입해 policy를 빠르게 test하고 실제 socket timeout은 작은 integration case로 확인합니다.

7. Test가 예상치 못한 network를 허용한다

Offline job은 기본 deny로 구성합니다.

직접 적용 체크리스트

  • Clock·ID·random·network가 주입 가능한 port다.
  • 예상하지 않은 call과 남은 script를 fake가 실패시킨다.
  • Fake와 HTTP adapter가 같은 contract suite를 통과한다.
  • Request serialization·status mapping·stream cleanup을 transport에서 검사한다.
  • Timeout·429·malformed JSON·truncated stream·cancel fault가 있다.
  • Exact prose 대신 ACL·citation·terminal·budget 불변식을 검사한다.
  • Cassette는 synthetic input과 allowlist redaction을 사용한다.
  • Base64 decode 후까지 secret·PII scan을 한다.
  • Offline CI는 예상하지 않은 network를 거부한다.
  • Live smoke는 opt-in·low-cost·low-privilege다.
  • Offline eval gate와 software correctness test가 분리돼 있다.
  • Flaky test의 seed·owner·재현 command를 보존한다.

스스로 확인하기

  1. Function mock보다 HTTP transport fake가 더 넓게 검증하는 경계는 무엇인가?
  2. Fake adapter에만 있는 편리한 동작이 왜 위험한가?
  3. Partial delta 뒤 read error를 무작정 retry하면 어떤 중복이 생길 수 있는가?
  4. Cassette에서 header만 redaction해도 부족한 이유는 무엇인가?
  5. Software test와 RAG offline eval의 실패가 각각 무엇을 뜻하는가?

자주 묻는 질문

Q1. Mock을 전혀 쓰지 말아야 하나요?

아닙니다. 좁은 interaction을 확인할 때 유용합니다. 다만 domain 경계 전체를 loose mock으로 대체하면 실제 protocol과 멀어지므로 typed fake와 contract test를 함께 둡니다.

Q2. Provider SDK 자체를 fake하면 충분한가요?

SDK event를 domain event로 변환하는 adapter는 검증할 수 있지만 raw HTTP serialization·status·stream parser를 SDK가 소유한다면 그 경계는 SDK update와 sparse live test로 확인합니다. 직접 HTTP adapter를 만들었다면 transport test가 필요합니다.

Q3. Record fixture는 얼마나 자주 갱신하나요?

Provider API version·SDK version이 바뀌거나 live contract가 drift를 감지할 때 갱신합니다. 이유 없이 golden을 덮어쓰지 말고 diff와 manual review를 남깁니다.

Q4. LLM 응답의 의미를 deterministic하게 어떻게 검사하나요?

핵심 claim·citation·abstention 같은 rubric을 structured evaluator로 검사합니다. Judge model을 쓰더라도 calibration dataset, version, threshold, human audit sample이 필요합니다.

Q5. Test pyramid 비율은 몇 퍼센트가 적당한가요?

고정 비율보다 feedback time과 failure localization이 기준입니다. 모든 commit에서 수초~수분 안에 핵심 contract를 검증하고, 비싼 live/eval은 merge·release cadence에 맞춥니다.

마무리

RAG Agent test harness는 다음 네 질문에 답해야 합니다.

  1. 같은 입력과 script를 다시 실행하면 같은 state transition을 얻는가?
  2. Fake와 real adapter가 같은 error·stream semantics를 제공하는가?
  3. 실제로 발생하기 어려운 failure를 빠르고 의도적으로 만들 수 있는가?
  4. 외부 서비스 없이도 release를 막을 핵심 불변식을 검증할 수 있는가?

다음 글에서는 지금까지 만든 application을 container로 묶고, CI gate·OpenTelemetry·canary·rollback과 release manifest까지 연결해 실제 배포 가능한 capstone으로 완결합니다.

검증 정보

  • 글의 성격: HTTPX·pytest·Python·Hypothesis 공식 문서 해설 + 작성자 test architecture 제안 + 가상 Python fixture
  • 마지막 검증일: 2026-07-16
  • 검증 기준: HTTPX MockTransport/AsyncByteStream, pytest fixture·monkeypatch, Hypothesis rule-based state machine
  • 실행 주의: pytest.mark.asyncio와 async fixture는 선택한 pytest async plugin version에 맞춰 설정
  • 수치 표기: queue·timeout·case 수와 CI 빈도는 설명용이며 repository feedback SLO와 비용으로 결정
  • 경험 표기: Provider path·request ID·golden case는 실제 운영 정보가 아닌 synthetic 예시

참고자료