# Testing AI — como se testa um agente sem rede, sem chave e sem variância

> **Scope:** stacks=["dotnet"]
> **Layer:** 1 (on-keyword)
> **Keywords:** testar agente, fake IChatClient, ScriptedChatClient, harness de turno, eval de agente, teste sem rede, golden de prompt, compatibilidade binaria
> **Read by Claude in:** implement (ao escrever o teste de qualquer código que fala com um modelo)

**Verified against:** Microsoft.Extensions.AI 10.9.0 + Microsoft.Agents.AI 1.20.0 (ai-pin 2026-09-08); provado por `templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs` e `.../src/Morph.AiKit/Providers/IProviderClientFactory.cs` — o fake roteirizado e a costura que dispensa rede são o que o kit compila e exercita. Harness de turno completo e evals com cache de resposta são **verificação documental** — o ai-kit não os traz. Last-verified: 2026-09-08.

---

## Fronteira com o testing genérico

`backend-dotnet-testing` continua dono do que é de teste em .NET: xUnit, `FakeTimeProvider`,
isolamento entre casos, nomes de teste, organização de fixtures. **Nada disso se repete aqui.**

Este standard cobre **só o que é de agente** — e o que é de agente cabe em uma frase: o único ponto
em que o seu código toca um provider é um `IChatClient`. Todo o resto deste arquivo é consequência
disso.

---

## Fake de IChatClient

**Roteirizado e burro.** Uma fila de respostas escritas à mão, servidas na ordem:

- resposta de texto;
- resposta com chamada de função (`FunctionCallContent`);
- resposta de texto com uso preenchido (para exercitar a conta de custo).

A fila guarda **respostas**, não exceções: no exemplar do kit, os três métodos de enfileiramento
recebem `ChatResponse` (direto, por texto, ou por texto + uso), e o único `throw` do fake é o de
**fila esgotada**. Para exercitar o caminho de falha do provider, injete um cliente que lança em vez
de enfileirar uma exceção — são costuras diferentes, e confundi-las faz o teste de falha passar pelo
motivo errado.

E do outro lado, **captura**: as mensagens recebidas, as opções recebidas e a contagem de chamadas —
uma entrada por chamada, na ordem. É o que permite assertar *o que foi ao provedor*, e não apenas
*o que voltou*.

**A regra que define o padrão: o fake não pode ser inteligente.** Ele não escolhe resposta por
conteúdo, não simula um modelo, não "responde razoavelmente". E, esgotada a fila, **ele lança**, com
a contagem exata de chamadas. Devolver uma resposta vazia em silêncio faria um teste passar por
engano — que é a única coisa pior do que um teste falhando.

O argumento mais forte a favor desta forma não é teórico: **duas implementações independentes, em
dois produtos que não se conhecem, chegaram à mesma coisa** (um `FakeChatClient` e um
`ScriptedChatClient`, com a mesma fila e a mesma captura). O ai-kit compila a terceira.

### A costura, e por que `sealed` não é desculpa para abri-la

O fake só serve se houver **um** ponto de injeção. No kit, esse ponto é uma interface de uma
operação — dado um alias resolvido, devolva o `IChatClient`. O registry permanece `sealed` e continua
sem saber construir cliente de provider nenhum.

> **O que o kit traz, e o que ele não traz.** A única implementação dessa interface no kit é a de
> **teste** (`FakeProviderClientFactory`) — medido. O adaptador de produção, aquele que faz o
> `switch` por provider e constrói o cliente de verdade, é **seu**: o kit prova que a costura
> funciona, não que o outro lado dela existe. É a mesma distinção de níveis do resto deste standard.

Precedente de campo com a **variante**: quando o registry é `sealed` e não se quer uma interface
pública, a costura foi um **construtor `internal`** recebendo uma função `alias -> IChatClient`,
exposta ao projeto de teste. As duas formas resolvem o mesmo problema; ambas preservam `sealed`, que
é a política da casa. **Costura de teste não é justificativa para tornar uma classe herdável.**

---

## Harness de turno

**Rode o loop real por cima do fake.** O valor não está em testar o fake; está em fazer o cliente de
invocação de funções **real** chamar as tools **reais**, com os clientes externos mockados.

É assim que se prova, sem rede e sem chave de API:

- que a tool escolhida foi a certa para aquele estado;
- que os argumentos montados batem;
- que o guard barrou o que devia barrar;
- que o teto de tool calls encerrou o turno onde devia;
- que o custo somou o que devia somar.

Nenhuma dessas cinco coisas é observável testando o compositor isolado — todas dependem do loop.

**Nota de ownership de recurso:** quem cria o cliente e quem o descarta precisa ser decidido uma vez.
Se a fábrica guardasse e reaproveitasse clientes por fora, existiriam duas caches da mesma coisa — e
duas caches da mesma coisa é como um cliente vazado sobrevive a um teste e polui o seguinte.

---

## Eval e unit

**"Eval" aqui não significa "modelo real".**

O padrão medido, e é o que dá mais retorno por hora escrita, é o **eval determinístico**: exercitar o
caminho **completo e real** — processador de turno, compositor, runner com middleware, tools,
persistência — com o comportamento do modelo **roteirizado**. Ganha-se cobertura de decisão sem
variância, e o teste falha por um motivo legível em vez de "o modelo respondeu diferente hoje".

Para o que **precisa** do modelo de verdade, o passo seguinte é uma biblioteca de avaliação com
**cache de resposta**, em **categoria própria** de teste — nunca misturada à suíte determinística. E
a regra que fecha: **gate duro só vale sobre propriedade determinística.** Reprovar um build porque
uma nota subjetiva caiu de 8,1 para 7,9 é transformar ruído em bloqueio.

**Zero chamada real ao provider dentro do `dotnet test`.** É uma propriedade **verificável
mecanicamente**, e o jeito de verificá-la é uma varredura sobre `tests/` procurando chave de
provider, leitura de variável de ambiente e cliente HTTP: ela precisa voltar **vazia**.

> **Quem faz essa varredura hoje: ninguém, automaticamente.** Medido em 2026-09-08 sobre o ai-kit
> (`grep -rniE "OPENAI_API_KEY|GetEnvironmentVariable|HttpClient|EnumerateFiles|Directory.Get"` em
> `templates/dotnet/ai-kit/**`): **zero ocorrências** — o que prova que o kit **não chama** o
> provider, e simultaneamente que **não existe teste que cobre essa ausência**. O kit descreve a
> propriedade em prosa (`FakeChatClient.cs`), não a guarda. Adotar esta regra significa **escrever a
> varredura no seu projeto**; herdá-la do kit é herdar uma intenção.

A medição de campo que sustenta a regra é do **acervo**, não do kit: `grep -c OPENAI_API_KEY` em
`tests/` = 0, sem nenhum teste marcado como pulado nem categoria de exclusão — ou seja, não há
"teste real desligado", há ausência de teste real.

**E o standard registra o preço disso**, porque ele é real: o harness com modelo de verdade daquele
projeto (onze cenários) **nunca foi rodado por completo ao vivo**. O que fica fora do `dotnet test`
tende a não rodar. Se um caminho precisa do modelo real, ele precisa de um dono e de um gatilho — ou
vai apodrecer.

---

## Captura do que foi ao provedor

O que se quer saber depois de um bug é: **o que exatamente foi enviado?**

Os dois artefatos que existem no campo, e nenhum deles se chama "captura da chamada crua":

1. **As opções recebidas pelo fake** — em teste, é a prova direta de que a lista de tools ofertadas
   saiu da lista real, de que o modo de tool era o esperado, e de que o esforço de raciocínio foi o
   do alias.
2. **O hash do texto que foi ao provedor** — em produção.

**A regra: prompt cru só em desenvolvimento e no harness, com opt-in explícito e sem dado pessoal;
em produção, hash.** Um prompt cru persistido em produção é um vazamento esperando indexação.

Duas asserções que o campo escreveu e que valem copiar: que a lista de tools ofertadas **saiu da
lista real** (e não de uma constante paralela), e que **turno cancelado não grava** — porque um
registro de algo que não aconteceu é pior que ausência de registro.

A metade de produção deste assunto — como o hash vira span, e a invariante que o torna confiável —
é de `ai-agents-observability-patterns`.

---

## Golden com parcimônia

Golden test congela uma saída em arquivo e compara. É excelente onde o **contrato é a forma**, e é
peso morto onde o conteúdo muda por motivo legítimo.

**Onde compensa:** o render de uma persona a partir de blocos, o contrato de saída estruturada, o
formato de um artefato publicado.

**Onde não compensa, com contra-exemplo real:** goldens de dois blobs de prompt foram **removidos de
propósito** de um projeto, com a justificativa medida — *"13,6 KB congelados para provar duas linhas
de concatenação"*. Cada edição de prompt exigia regravar 13,6 KB, o diff era ilegível, e a revisão
virava um "aceitar tudo". Um golden que ninguém lê no diff não é teste: é atrito.

O critério: **golden prova forma, não conteúdo.** Se a asserção real é "estas duas linhas
concatenam", escreva as duas linhas.

### Teste de compatibilidade binária

Técnica, não filosofia: comparar em runtime a versão do assembly do SDK carregado com a que a ponte
referencia, e conferir a **assinatura** do membro que já quebrou uma vez. É barato, roda sem rede, e
pega a classe de defeito que custou uma chamada paga sem resposta. Ponte com
`ai-agents-llm-runtime-defaults`, seção de pin de SDK.

### Prompt versionado: no máximo um teste de estrutura

O que se quer provar de um prompt é o que o **modelo faz** com ele — e isso só se observa em
runtime: eval com cache (`ai-agents-evals-with-cache`), com a evidência da execução registrada.
Sobre a fonte (migration, YAML), **no máximo um** teste por prompt, e só de forma: as
**chaves/placeholders** que o consumidor lê e a **ausência** de instrução revogada (a regressão
que o rollback traz de volta — `ai-agents-prompt-sources`, reconciliação). Nunca presença de
frases que o modelo "deveria" seguir. Contraexemplo medido: 12 suítes, 106 `Contains` sobre a
migration do prompt, todos sobre versões superadas e verdes com qualquer resposta do modelo — o
mesmo padrão que este standard já contou como precedente ("treze arquivos, ~2.500 linhas").

---

## Anti-patterns

| Anti-pattern | Por quê é problema | Solução |
|---|---|---|
| Fake "inteligente" que escolhe resposta por conteúdo | Você passa a testar o fake | Fila roteirizada, servida na ordem |
| Fake que devolve vazio quando a fila acaba | O teste passa por engano | Lance, com a contagem de chamadas |
| Testar só o compositor, sem o loop real | Tool escolhida, guard e teto ficam sem cobertura | Harness com o cliente de invocação real |
| Chamada real ao provider dentro do `dotnet test` | Custo, flake e dependência de rede na suíte | Zero; e verifique com varredura |
| Harness com modelo real sem dono nem gatilho | Onze cenários que nunca rodaram por completo | Dono, gatilho e categoria própria |
| Gate duro sobre nota subjetiva de eval | Ruído virando bloqueio de build | Gate só sobre propriedade determinística |
| Prompt cru persistido em produção | Vazamento esperando indexação | Hash em produção; cru só em dev/harness com opt-in |
| Gravar telemetria de turno cancelado | Registro de algo que não aconteceu | Não grave |
| Golden de blob de prompt | 13,6 KB congelados para provar duas linhas | Asserte as duas linhas |
| Suíte estrutural sobre o texto do prompt | Congela a versão, não o comportamento | 1 teste de estrutura + eval de runtime |
| Abrir a classe (`sealed` removido) para poder testar | Costura de teste virando decisão de arquitetura | Interface de uma operação, ou construtor `internal` |
| Fábrica que guarda e reaproveita clientes por fora | Duas caches da mesma coisa; cliente vazado entre testes | Ownership decidido num lugar |

---

## Checklist (verifiable by morph-eval)

- [ ] Existe um fake de `IChatClient` roteirizado, com captura de mensagens, opções e contagem
- [ ] O fake **lança** quando a fila acaba
- [ ] Há ao menos um teste que roda o loop **real** de invocação de funções por cima do fake
- [ ] Existe **no seu projeto** um teste que varre `tests/` por chave de provider, leitura de ambiente
      e cliente HTTP, e ele volta vazio (o ai-kit **não** traz essa varredura — medido; herdá-la do
      kit é herdar uma intenção)
- [ ] Nenhum teste real de modelo está apenas "pulado" — ou tem dono e gatilho, ou não existe
- [ ] Nenhum gate duro sobre nota subjetiva de eval
- [ ] Existe asserção de que a lista de tools ofertadas saiu da lista real
- [ ] Turno cancelado não grava telemetria
- [ ] Nenhum golden de blob de prompt; goldens existentes provam **forma**
- [ ] Existe teste de compatibilidade binária do par SDK × ponte
- [ ] Cada fonte de prompt tem **no máximo 1** teste de estrutura (chaves + ausência de instrução
      revogada); critério sobre saída do modelo é eval de runtime
- [ ] Nenhuma classe deixou de ser `sealed` por causa de teste

---

## References

- `backend-dotnet-testing` — dono do testing .NET genérico (xUnit, `FakeTimeProvider`, isolamento).
  Nada dele se repete aqui
- `ai-agents-llm-runtime-defaults` — o pin de SDK que o teste de compatibilidade binária protege
- `ai-agents-prompt-sources` — a fonte versionada que o teste de prompt como texto lê
- `ai-agents-observability-patterns` — a metade de produção da captura (hash, span, invariante)
- `ai-agents-conversational-agent-with-phases` — compositor, allow-list e teto: o que o harness exercita
- `ai-agents-cost-and-budget` — a conta que o harness confere com uso roteirizado
- `ai-agents-guardrails` — os guards que só o loop real prova
- `ai-agents-evals-with-cache` — o eval de runtime que prova o que o modelo faz com o prompt

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/testing-ai.md v1.1 (2026-09-14)*
