Microsoft Agent Framework 애플리케이션 테스트하기
요약
Microsoft Agent Framework를 활용한 에이전트 애플리케이션 테스트 전략을 다룹니다. 라이브 모델에 의존하는 대신 테스트 피라미드 구조를 통해 단위 테스트부터 평가 스타일 테스트까지 단계별로 검증하는 방법을 제시합니다.
핵심 포인트
- 라이브 모델 테스트의 비용, 속도, 불안정성 문제 지적
- 테스트 피라미드 구조를 통한 단계별 검증 권장
- 가짜 모델 클라이언트 및 도구 계약 테스트 활용
- 결정론적 테스트와 평가 스타일 테스트의 조화
이 글은 Microsoft Agent Framework에 관한 시리즈의 18번째 파트입니다. 원문 포스트는 lukaswalter.dev에서 읽으실 수 있습니다.
이전 글에서 우리는 에이전트(agent)를 위한 관찰 가능성(observability)을 살펴보았습니다.
핵심 아이디어는 실행 과정을 모델 호출(model calls), 도구 호출(tool calls), 승인(approvals), 그리고 워크플로 이벤트(workflow events)의 체인으로 가시화하는 것이었습니다.
테스트 또한 동일한 아이디어에서 시작됩니다.
에이전트 실행은 단 하나의 답변 문자열이 아닙니다.
그것은 다음과 같은 여러 경계(boundaries)를 가진 작은 애플리케이션 흐름입니다:
사용자 입력 (user input)
-> 프롬프트 및 컨텍스트 (prompt and context)
-> 모델 요청 (model request)
...
만약 테스트가 라이브 모델(live model)을 대상으로 한 엔드 투 엔드(end-to-end) 프롬프트뿐이라면, 이 모든 경계가 서로 뒤섞이게 됩니다.
테스트가 실패했을 때, 문제가 프롬프트인지, 모델인지, 도구 스키마(tool schema)인지, 라우터(router)인지, 워크플로(workflow)인지, 아니면 도구 뒤에 있는 실제 의존성(dependency)인지 알 수 없게 됩니다.
해결책은 LLM이 결정론적(deterministic)이라고 가정하는 것이 아닙니다.
해결책은 각 경계를 결정론적인 수준에서 테스트한 다음, 모델에 진정으로 의존하는 동작에 대해서는 소수의 평가 스타일(evaluation-style) 테스트를 추가하는 것입니다.
이 포스트에서 다루는 내용은 다음과 같습니다:
- 가짜 모델 클라이언트 (fake model clients)
- 도구 계약 테스트 (tool contract tests)
- 구조화된 출력 테스트 (structured output tests)
- 라우팅 테스트 (routing tests)
- 워크플로 테스트 (workflow tests)
- 평가 스타일 회귀 체크 (eval-style regression checks)
예제는 xUnit 스타일의 어설션(assertions)을 사용하지만, 테스트 접근 방식이 xUnit에 의존하는 것은 아닙니다.
코드 스니펫은 관련 테스트 경계에 집중하며, 애플리케이션 특유의 팩토리(factory) 및 워크플로 설정은 일부 생략되었습니다.
라이브 모델로 시작하지 마세요
라이브 모델 테스트는 유용합니다.
하지만 비용이 많이 들고, 느리며, 때로는 불안정(flaky)하고, 진단하기 어렵습니다.
이러한 점 때문에 일반적인 단위 테스트(unit tests) 및 통합 테스트(integration tests)를 대체하기에는 부적합합니다.
저는 에이전트 애플리케이션을 위해 테스트 피라미드(testing pyramid)를 사용합니다:
평가 (evals)
현실적인 모델 및 사용자 예시
...
가장 하위 계층(bottom layer)이 가장 커야 합니다.
이 계층은 Azure OpenAI, OpenAI, Foundry 또는 다른 제공업체에 연락하지 않고도 일반적인 프로그래밍 실수를 잡아내야 합니다.
중간 계층(middle layer)은 각 구성 요소들이 함께 잘 작동하는지 증명합니다.
가짜 IChatClient, 인메모리 리포지토리(in-memory repository), 그리고 실제 워크플로 실행(workflow execution)을 사용할 수 있습니다.
최상위 계층(top layer)은 일반적인 단언(assertions)만으로는 완전히 명시할 수 없는 동작을 확인합니다:
- 답변이 유용한지
- 모호한 요청에 대해 경로(route)가 적절한지
- 요약이 중요한 사실을 보존하는지
- 에이전트가 현실적인 대화에서 정책을 따르는지
마지막 계층은 단위 테스트(unit testing)보다는 평가(evaluation)에 더 가깝습니다.
이는 테스트 전략에 포함되어야 하지만, 전략 전체를 담당해서는 안 됩니다.
IChatClient 뒤에 모델 배치하기
가장 중요한 테스트 심(test seam)은 모델 클라이언트입니다.
Microsoft Agent Framework는 IChatClient를 포함한 Microsoft.Extensions.AI 추상화(abstractions)를 기반으로 구축됩니다.
이는 애플리케이션이 프로덕션 환경에서는 실제 제공업체를 사용하는 에이전트를 구성하고, 테스트 환경에서는 스크립트된 클라이언트(scripted client)를 사용할 수 있음을 의미합니다.
가짜 클라이언트는 지능을 시뮬레이션할 필요가 없습니다.
테스트 시나리오가 요구하는 응답을 반환하고 애플리케이션이 클라이언트에 무엇을 보냈는지 기록하기만 하면 됩니다.
저는 Moq나 NSubstitute로 IChatClient를 모킹(mocking)하는 것보다 이러한 작은 가짜(fake)를 사용하는 것을 선호합니다.
응답 스트림(Response streams)과 ChatResponse에서 ChatResponseUpdate로의 변환을 올바르게 구성하는 것은 번거롭지만, 가짜 클라이언트는 실제 인터페이스와 변환 경로를 실행하기 때문입니다.
다음은 작은 스크립트된 클라이언트의 예시입니다:
using Microsoft.Extensions.AI;
public sealed class ScriptedChatClient : IChatClient
...
이것은 의도적으로 단순하게 작성되었습니다.
이를 통해 테스트에 세 가지 유용한 기능을 제공합니다:
- 모델 제공업체를 절대 호출하지 않습니다.
- 애플리케이션이 시나리오에서 예상하는 것보다 더 많은 모델 호출을 수행하면 실패합니다.
- 테스트가 모델 경계(model boundary)로 전송된 메시지와 옵션을 검사할 수 있게 해줍니다.
실제 테스트 프로젝트라면, 저는 보통 몇 가지 기능을 더 추가할 것입니다:
- 사용 메타데이터 (usage metadata)를 포함하는 스크립트된 응답 (scripted responses)
- 요청을 검사할 수 있는 응답 팩토리 (response factory)
- 스트리밍 업데이트를 위한 별도의 큐 (separate queue)
- 요청이 JSON 스키마 (JSON schema) 출력을 요청했는지 여부를 기록하는 플래그 (flag)
- 타임아웃 테스트를 위한 취소 지원 (cancellation support)
가짜 클라이언트 (fake client)가 두 번째 모델 구현체가 될 정도로 똑똑하게 만들지 마세요.
핵심은 시나리오를 제어하는 것이지, 제공자 (provider)의 동작을 재현하는 것이 아닙니다.
에이전트 경계 (agent boundary) 테스트하기
클라이언트를 주입 (injectable)할 수 있게 되면, 애플리케이션에 있는 것과 정확히 동일하게 에이전트를 구축할 수 있습니다.
예를 들어, 이 팩토리는 모델 클라이언트를 테스트 자체 외부로 유지합니다:
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
...
이제 기본적인 테스트를 통해 네트워크 호출 없이 에이전트의 애플리케이션 경계를 검증할 수 있습니다:
[Fact]
public async Task Agent_returns_scripted_answer()
{
...
이 테스트는 실제 모델이 동일한 방식으로 응답할 것임을 증명하는 것이 아닙니다.
그것은 이 테스트가 증명하려는 목적이 아닙니다.
이 테스트는 다음을 증명합니다:
- 에이전트가 구성된 클라이언트로 구축될 수 있음
- 입력이 모델 경계 (model boundary)에 도달함
- 응답이 애플리케이션 API를 통해 반환됨
이것만으로도 이미 충분히 가치가 있습니다.
또한 기록된 요청을 사용하여 프롬프트 구성 (prompt construction), 컨텍스트 제공자 (context providers), 채팅 리듀서 (chat reducers) 또는 세션 동작을 테스트할 수도 있습니다.
예를 들어, 테스트를 통해 요청에 테넌트 ID (tenant id)가 포함되어 있는지, 그리고 축소된 히스토리 (reduced history)가 알려진 메시지 수를 초과하지 않는지 확인할 수 있습니다.
프롬프트 자체가 계약 (contract)인 경우가 아니라면, 직렬화된 전체 프롬프트를 검증하는 것은 피하세요.
작은 지시 사항의 변경이 모든 테스트를 깨뜨려서는 안 됩니다.
대신 중요한 불변량 (invariant)을 검증하세요:
Assert.Contains(
client.Requests.Single().Select(message => message.Text),
text => text?.Contains("tenant-42", StringComparison.Ordinal) == true);
도구 (Tool) 테스트는 두 가지 서로 다른 작업을 수행합니다
도구는 최소 두 가지 계약을 가집니다:
- 함수를 실행하는 C# 계약 (C# contract)
- 모델에 함수를 설명하는 AI 계약 (AI contract)
두 가지 모두 테스트하세요.
함수를 일반 애플리케이션 코드로서 테스트하기
함수 자체는 일반적인 단위 테스트 (unit tests)를 가져야 합니다.
단순히 AIFunction으로 노출되었다는 이유만으로 LLM (Large Language Model)을 필요로 해서는 안 됩니다.
public sealed record TicketStatus(
int TicketId,
string State,
...
서비스를 직접 테스트하세요:
[Fact]
public async Task Ticket_service_returns_current_status()
{
...
이를 통해 비즈니스 로직 오류, 권한 설정 실수, 잘못된 매핑, 그리고 의존성 실패를 잡아낼 수 있습니다.
이는 에이전트가 잘못된 답변을 했다고만 알려주는 엔드 투 엔드 테스트 (end-to-end test)보다 진단하기 훨씬 쉽습니다.
생성된 AI 계약 (AI contract) 테스트하기
이제 해당 메서드를 함수로 노출합니다.
서비스를 모델이 제어하는 인자 목록 (argument list)에 추가하는 대신, 신뢰할 수 있는 인스턴스 상태 (trusted instance state)로 유지하세요:
using System.ComponentModel;
using Microsoft.Extensions.AI;
...
테스트는 에이전트가 받는 것과 동일한 AIFunction을 생성하고 그 선언을 검사해야 합니다.
TicketService 인스턴스는 SupportTools에 의해 저장되고 델리게이트 (delegate)에 의해 캡처되므로, AI에 노출되는 JSON 스키마 (JSON schema)에는 나타나지 않습니다.
[Fact]
public void Ticket_tool_exposes_a_narrow_contract()
{
...
중요한 단언 (assertions)은 반드시 생성된 정확한 JSON 문자열일 필요는 없습니다.
대신 모델의 동작에 영향을 미치는 속성들이 중요합니다:
- 도구가 의도한 이름을 가지고 있는가
- 설명 (description)이 실제로 수행하는 작업을 말하고 있는가
- 필수 파라미터 (required parameters)가 존재하는가
- 파라미터가 의도한 타입을 가지고 있는가
- 내부 서비스가 모델 파라미터로 노출되지 않았는가
- 모델이 필요할 때 결과 스키마 (result schema)가 존재하는가
생성된 JSON 스키마는 프롬프트 경계 (prompt boundary)의 일부입니다.
이름 변경, Description 누락, 또는 실수로 노출된 서비스 파라미터는 C# 함수가 여전히 컴파일되더라도 도구 선택 (tool selection)을 변화시킬 수 있습니다.
도구 선택과 부수 효과 (side effects)를 분리하여 테스트하기
도구 계약 테스트는 에이전트가 해당 도구를 선택할 것임을 증명하지 않습니다.
그것은 도구가 올바르게 설명되어 있음을 증명할 뿐입니다.
부수 효과 (side effects)에 있어서는 그 차이가 매우 중요합니다.
이메일을 보내거나, 데이터를 삭제하거나, 릴리스를 배포하거나, 카드를 결제하는 도구의 경우 다음 사항에 대한 테스트를 추가하십시오:
- 권한 부여 (authorization)
- 모든 인자 (argument)의 유효성 검사 (validation)
- 멱등성 (idempotency) 동작
- 승인 처리 (approval handling)
- 취소 (cancellation)
- 중복 호출 (duplicate calls)
- 실패 및 재시도 동작 (failure and retry behavior)
그런 다음, 도구가 예상된 승인 경계 (approval boundary)로 래핑되어 있는지 테스트하십시오. 모델이 함수를 요청할 수는 있지만, 모델이 요청했다고 해서 함수가 즉시 실행되어서는 안 됩니다.
구조화된 출력 (Structured output)은 형태 테스트와 의미 테스트가 필요합니다
구조화된 출력은 애플리케이션 코드에 비구조화된 문자열 대신 타입을 부여합니다. 하지만 이것이 모델의 값이 올바르다는 것을 보장하지는 않습니다.
따라서 두 가지 테스트 범주가 생깁니다.
성공적인 역직렬화 (deserialization) 테스트
Microsoft.Extensions.AI 구조화된 출력 확장은 IChatClient로부터 타입이 지정된 ChatResponse<T>를 요청할 수 있습니다. 가짜 클라이언트 (fake client)를 사용하면 응답을 JSON으로 제어할 수 있습니다:
public sealed record IntentResult(
string Intent,
double Confidence,
...
useJsonSchemaResponseFormat: false 인자는 이 테스트가 역직렬화에만 집중할 수 있도록 합니다. 제공자 (provider)가 네이티브 JSON 스키마 응답 포맷팅을 지원하는지 확인하려면 별도의 통합 테스트 (integration test)를 수행할 수 있습니다.
또한 잘못된 형식의 출력 (malformed output)도 테스트하십시오:
[Fact]
public async Task Structured_output_test_fixture_can_expose_invalid_model_output()
{
...
모델이 요청된 스키마를 준수한다는 보장은 없습니다. 애플리케이션에는 여전히 파싱할 수 없는 응답에 대한 정책이 필요합니다:
- 수정 요청 (repair request)과 함께 재시도
- 제어된 에러 반환
- 사람에게 라우팅
- 안전한 폴백 (fallback) 사용
테스트는 해당 정책이 적용되는지 증명해야 합니다.
역직렬화 후 비즈니스 유효성 검사 테스트
response.Result 단계에서 멈추지 마십시오.
라우터의 경우, 유효한 IntentResult라 하더라도 여전히 안전하지 않을 수 있습니다:
var result = new IntentResult(
Intent: "delete-production-data",
Confidence: 0.99,
...
JSON은 유효합니다. 하지만 의도 (intent)가 허용된 경로 중 하나가 아닙니다.
다음 유효성 검사 단계는 일반적인 C#으로 유지하십시오:
public enum UserIntent
{
Support,
...
유효하지 않은 열거형 (enum) 값, 누락된 필드, 범위를 벗어난 신뢰도 (confidence), 그리고 낮은 신뢰도의 결과에 대해 테스트하십시오.
이러한 테스트는 모델이 구조적으로 유효한 객체를 생성하더라도 애플리케이션 경계 (application boundary)를 보호합니다.
라우팅 (Routing) 테스트는 대부분 일반적인 C# 테스트여야 합니다
수동 라우팅 기사에서는 작은 의도 (intent) 에이전트를 사용한 뒤 C# switch 문을 사용했습니다.
이러한 분리는 라우팅을 테스트하기 더 쉽게 만듭니다.
모델은 분류 (classify) 합니다.
애플리케이션은 라우팅 (route) 합니다.
라우트 함수는 알려진 결정 사항이 담긴 테이블로 테스트할 수 있습니다:
[Theory]
[InlineData(UserIntent.Support, "support-agent")]
[InlineData(UserIntent.Billing, "billing-agent")]
...
실제 애플리케이션 메서드는 델리게이트 (delegate), 에이전트 (agent), 또는 커맨드 객체 (command object)를 반환할 수 있습니다.
테스트 원칙은 동일합니다: 모델이 검증된 라우트를 생성하고 나면, 라우팅은 결정론적 (deterministic) 이어야 합니다.
또한 라우트 경계 (route boundaries)를 테스트해야 합니다:
- 낮은 신뢰도 시 명확한 설명을 요청함
- 알 수 없는 의도 (intent) 시 안전한 폴백 (fallback)을 사용함
- 제한된 테넌트 (tenant)가 권한이 있는 에이전트를 선택할 수 없음
- 승인이 필요한 요청이 승인 경로를 우회할 수 없음
- 전문가 (specialist)가 원래의 사용자 입력과 최소한으로 필요한 컨텍스트 (context)를 전달받음
- 폴백 (fallback)이 실수로 비용이 많이 드는 전문가를 호출하지 않음
그 다음, 스크립트된 응답을 사용하여 모델 기반 분류기 (model-based classifier)를 별도로 테스트하십시오:
[Theory]
[InlineData("Where is my invoice?", "billing")]
[InlineData("The login link is broken.", "support")]
...
이는 분류기 통합 (classifier integration)을 확인합니다.
이것이 모델이 모든 실제 사용자 메시지를 정확하게 분류할 것이라고 주장하는 것은 아닙니다.
그 역할은 평가 세트 (eval set)의 몫입니다.
워크플로 (Workflow) 테스트는 이벤트와 출력을 단언 (assert) 해야 합니다
워크플로는 최종 출력 그 이상입니다.
이전 워크플로 기사들에서는 명시적인 실행기 (executors), 엣지 (edges), 이벤트 (events), 그리고 인간 참여 (human-in-the-loop) 일시 중지를 다루었습니다.
이것들은 모두 테스트 가능합니다.
간단한 워크플로 (workflow)의 경우, 프로세스 내부 (in-process)에서 실행하고 방출된 이벤트 (events)를 검사하십시오:
using Microsoft.Agents.AI.Workflows;
[Fact]
...
정확한 이벤트 (event) 유형과 워크플로 (workflow) 출력은 워크플로 (workflow)에 따라 달라집니다.
일반적으로 유용한 단언 (assertions)은 다음과 같습니다:
- 예상된 실행기 (executor)가 실행되었는지 여부
- 출력이 예상된 유형 (type)을 가졌는지 여부
- 분기 (branch)가 선택되었는지 또는 선택되지 않았는지 여부
- 단계 (step)가 실패했을 때 에러 이벤트 (error event)가 방출되었는지 여부
- 승인 요청 (approval request)에서 워크플로 (workflow)가 중단되었는지 여부
- 응답과 함께 재개 (resuming)했을 때 모든 것을 다시 시작하는 대신 일시 중지 (pause)된 지점부터 계속되는지 여부
잠금 단계 실행 (Lockstep execution)은 이벤트 (event) 순서를 결정론적 (deterministic)으로 만들기 때문에 테스트에 유용합니다.
이는 특히 프로덕션 워크플로 (production workflow)가 스트리밍 실행 (streaming execution)을 사용하고 이벤트 (events)가 도착하는 동안...
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기