Testar sem gastar tokens
O modelo é a única peça do agente que você não controla. Um fake que implementa o behaviour do ChatModel devolve o controle à suíte.
Selecione um trecho para perguntar à assistente.
O agente das três partes anteriores funciona — e qualquer teste que o atravesse chama a API de verdade. São três impostos por rodada de mix test: dinheiro, porque cada chamada é cobrada; tempo, porque cada uma leva segundos; e confiança, porque o modelo formula a resposta de um jeito diferente a cada execução, e um assert sobre o texto passa hoje e quebra amanhã sem nada ter mudado no seu código.
A saída está no jeito como a biblioteca usa o modelo:
# dentro da biblioteca, simplificado
%module{} = chain.llm
module.call(chain.llm, chain.messages, chain.tools)
Ela extrai o módulo do struct que está em llm e chama call/3 nele — logo depois de injetar os callbacks da chain nesse struct, detalhe que vai importar adiante. Qualquer struct serve, desde que o módulo implemente o behaviour LangChain.ChatModels.ChatModel. O ChatAnthropic é uma implementação. Um fake seu é outra.
A resposta vem do teste
defmodule MyApp.FakeModel do
@behaviour LangChain.ChatModels.ChatModel
# A chain injeta os callbacks dela com `%{llm | callbacks: ...}`;
# sem o campo, a atualização do struct quebra.
defstruct reply: nil, callbacks: []
def new!(attrs \\ %{}), do: struct!(__MODULE__, attrs)
@impl true
def call(%__MODULE__{} = model, messages, tools), do: model.reply.(messages, tools)
@impl true
def retry_on_fallback?(_error), do: false
@impl true
def serialize_config(_model), do: %{}
@impl true
def restore_from_map(_data), do: {:error, "um fake não se restaura"}
end
São quatro callbacks obrigatórios, e três respondem o mínimo: o fake fica de fora de fallback e de serialização. O comportamento inteiro mora em reply, uma função que cada teste entrega pronta. O fake não decide nada, e essa é a diferença que interessa: no teste, quem faz o papel do modelo é você.
A costura no GenServer
O init/1 da primeira parte criava o ChatAnthropic dentro de si, e o que nasce dentro não se troca por fora. A mudança é o modelo virar opção com default:
@impl true
def init(opts) do
llm =
Keyword.get_lazy(opts, :llm, fn ->
ChatAnthropic.new!(%{model: "claude-sonnet-5"})
end)
chain =
%{llm: llm}
|> LLMChain.new!()
|> LLMChain.add_message(Message.new_system!(Keyword.fetch!(opts, :system_prompt)))
{:ok, %{chain: chain}, @idle_timeout}
end
Produção não passa :llm e continua com o mesmo ChatAnthropic de sempre; o teste passa o fake. É injeção de dependência completa em uma linha, sem biblioteca para isso. O get_lazy poupa a construção de um modelo que seria descartado sempre que o teste traz o seu.
O que dois números provam
defmodule MyApp.ConversationTest do
use ExUnit.Case, async: true
alias LangChain.LangChainError
alias LangChain.Message
alias LangChain.Message.ContentPart
alias MyApp.{Conversation, FakeModel}
test "a chain reenvia a conversa inteira a cada rodada" do
fake =
FakeModel.new!(%{
reply: fn messages, _tools ->
{:ok, Message.new_assistant!("recebi #{length(messages)} mensagens")}
end
})
start_supervised!({Conversation, id: "t-1", system_prompt: "seja breve", llm: fake})
{:ok, first} = Conversation.ask("t-1", "oi")
{:ok, second} = Conversation.ask("t-1", "continua")
assert [%ContentPart{content: "recebi 2 mensagens"}] = first.content
assert [%ContentPart{content: "recebi 4 mensagens"}] = second.content
end
end
O fake devolve o tamanho da lista que recebeu, e os números contam a história. Primeira rodada: o system prompt e a pergunta, duas mensagens. Segunda: essas duas, mais a resposta anterior do modelo, mais a pergunta nova — quatro. Sem rede e em milissegundos, o teste acabou de provar que o GenServer acumula o histórico e o reenvia inteiro a cada rodada, que é exatamente o que a primeira parte prometeu.
O match numa lista de ContentPart não é capricho: é o formato em que a mensagem carrega texto por dentro, o mesmo que apareceu nos deltas da parte anterior. E o start_supervised! sobe a conversa sob o supervisor do próprio teste, que a derruba no fim; com um id único por teste, o Registry da aplicação não vê colisão nem com async: true.
O pior dia do provedor, em uma linha
test "erro do provedor não derruba a conversa" do
fake =
FakeModel.new!(%{
reply: fn _messages, _tools ->
{:error, LangChainError.exception(type: "overloaded", message: "provedor sobrecarregado")}
end
})
pid = start_supervised!({Conversation, id: "t-2", system_prompt: "seja breve", llm: fake})
assert {:error, %LangChainError{type: "overloaded"}} = Conversation.ask("t-2", "oi")
assert Process.alive?(pid)
end
Esse caminho o modelo real quase nunca deixa ensaiar: o provedor não fica sobrecarregado no horário dos seus testes. Com o fake, o pior dia dele custa uma linha. O assert final é o que importa: a conversa engoliu o erro e segue viva — o comportamento que o handle_call da primeira parte escolheu quando descartou a chain com erro e ficou com o último estado bom.
E a reply alcança mais longe. Devolver uma mensagem com tool_calls faz o loop de tools da segunda parte rodar inteiro dentro do teste, com a sua validação no meio. Um Process.sleep antes de responder ensaia o modelo lento e os timeouts da primeira parte. Cada comportamento do provedor que já custou uma madrugada vira uma função de três linhas.
O que o fake não prova
O fake prova o encanamento: o histórico cresce, o erro não derruba, a tool executa, o delta chega. O que ele não prova é a única coisa que o modelo real faz — decidir. Se o prompt leva o modelo a chamar lookup_order na hora certa, se a resposta sai no tom que você pediu: isso só uma chamada real responde. Verificação de comportamento com chamadas reais tem nome próprio, evals, e é assunto para fora desta série.
A divisão que fica de pé é essa: o encanamento se prova com o fake, a cada mix test, de graça; o comportamento do modelo se verifica à parte, com chamadas reais, poucas e deliberadas.
O agente agora se prova de graça a cada mix test. O que ele ainda não faz é sobreviver: a primeira parte avisou que, reiniciado o nó, a conversa evapora — e deploy acontece em dia de semana, com conversas abertas. A próxima parte grava cada rodada no banco sem tirar o processo do centro.