Para testar código que chama um LLM no Laravel, eu separo o problema em duas camadas. A primeira são testes determinísticos: rodam no CI com Http::fake e respostas reais gravadas em fixtures, sem chamar a API nem gastar tokens. A segunda são evals: um conjunto de casos rodado contra o modelo de verdade para medir a taxa de acerto. Os testes garantem que o seu código funciona. Os evals garantem que o prompt e o modelo continuam acertando.
No final deste tutorial você vai ter:
- Um cliente HTTP para a API do Claude (Anthropic) com timeout e retry, isolado numa classe.
- Um classificador de mensagens de WhatsApp com saída estruturada e validação.
- Um comando Artisan que grava respostas reais da API como fixtures.
- Testes PHPUnit que cobrem o caminho feliz, o payload enviado (incluindo mascaramento de CPF por LGPD), retries em erro 529/429 e saída inválida do modelo.
- Um comando
llm:evalque roda um dataset contra a API real e falha abaixo de um percentual mínimo de acerto.
Pré-requisitos: PHP 8.2+, Laravel 11 ou 12, PHPUnit (vem por padrão; com Pest a ideia é a mesma) e uma chave da API da Anthropic. Não uso SDK de terceiros: chamo a API por HTTP com o Http:: do Laravel. Isso tem um motivo prático, que fica claro no passo 5: o Http::fake só intercepta requisições feitas pelo client HTTP do Laravel.
Por que testar código com LLM é diferente?
Um LLM (Large Language Model, o modelo que gera texto, como Claude ou GPT) é não determinístico: a mesma entrada pode gerar saídas diferentes. Ele também é caro e lento para rodar a cada php artisan test, e falha de jeitos próprios: sobrecarga (HTTP 529 na Anthropic), rate limit (429) e respostas que fogem do formato combinado.
Por isso não dá para escrever um teste que chama a API e compara o texto com assertSame. Ele vai quebrar aleatoriamente e queimar crédito no CI. O que funciona na prática:
| Camada | O que verifica | Chama a API? | Quando roda |
|---|---|---|---|
| Testes com fake | Seu código: payload, parsing, validação, retries | Não | Todo push |
| Fixtures gravadas | Que o parsing aguenta o formato real da resposta | Só ao gravar | Ao mudar prompt ou modelo |
| Evals | Qualidade do prompt + modelo num dataset | Sim | Manual ou agendado |
O exemplo deste post é um classificador de intenção para um bot de agendamento no WhatsApp. É a peça que decide se a mensagem é "quero marcar", "quero cancelar" ou "paguei, cadê o comprovante?". Se você quer ver o agente completo, com ferramentas que consultam agenda, tem o post sobre criar um agente de IA com tool calling do Claude no Laravel para agendamentos no WhatsApp. Aqui o foco é testar.
1. Configurar credenciais e parâmetros da API
No .env:
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-5 ANTHROPIC_TIMEOUT=30 ANTHROPIC_RETRY_SLEEP_MS=1000
Em config/services.php:
'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'), 'timeout' => (int) env('ANTHROPIC_TIMEOUT', 30), 'retry_sleep_ms' => (int) env('ANTHROPIC_RETRY_SLEEP_MS', 1000), ],
No phpunit.xml, dentro de <php>, coloque uma chave falsa e zere o intervalo entre retries. Assim os testes nunca usam a chave real e não ficam esperando:
<env name="ANTHROPIC_API_KEY" value="fake-key"/> <env name="ANTHROPIC_RETRY_SLEEP_MS" value="0"/>
2. Como isolar a chamada ao Claude num cliente HTTP?
Toda chamada à API passa por uma única classe. Isso concentra headers, timeout e política de retry, e deixa o resto do código falando só em arrays.
<?php namespace App\Services\Llm; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; class ClaudeClient { public function messages(array $payload): array { return Http::baseUrl('https://api.anthropic.com/v1') ->withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ]) ->acceptJson() ->asJson() ->connectTimeout(5) ->timeout(config('services.anthropic.timeout')) ->retry( 3, config('services.anthropic.retry_sleep_ms'), fn ($e) => $e instanceof ConnectionException || ($e instanceof RequestException && in_array($e->response->status(), [429, 500, 529], true)), ) ->post('/messages', $payload + ['model' => config('services.anthropic.model')]) ->json(); } }
Dois detalhes importam aqui. O callback do retry só repete erros transitórios: conexão, rate limit e sobrecarga. Um 400 (payload inválido) falha na hora, porque repetir não resolve. E como o terceiro argumento throw do retry é true por padrão, depois da última tentativa o Laravel lança RequestException, então não precisa de ->throw().
3. Criar o classificador com saída estruturada e validação
Saída estruturada é forçar o modelo a responder num formato definido por JSON Schema em vez de texto livre. Na API da Anthropic eu faço isso declarando uma ferramenta e usando tool_choice para obrigar o modelo a chamá-la. O resultado chega num bloco tool_use com o campo input já em JSON. Explorei isso mais a fundo no post sobre extrair dados de comprovantes em PDF com Claude e saída estruturada.
<?php namespace App\Services\Llm; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use UnexpectedValueException; class ClassificadorDeMensagem { public const INTENCOES = ['agendar', 'remarcar', 'cancelar', 'pagamento', 'outro']; public function __construct(private ClaudeClient $claude) {} public function classificar(string $mensagem): array { $resposta = $this->claude->messages([ 'max_tokens' => 300, 'system' => 'Você classifica mensagens de clientes de um sistema de agendamento. ' .'Responda sempre usando a ferramenta registrar_intencao.', 'tools' => [[ 'name' => 'registrar_intencao', 'description' => 'Registra a intenção detectada na mensagem do cliente.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'intencao' => ['type' => 'string', 'enum' => self::INTENCOES], 'data_mencionada' => [ 'type' => ['string', 'null'], 'description' => 'Data no formato YYYY-MM-DD, se houver.', ], 'confianca' => ['type' => 'number', 'minimum' => 0, 'maximum' => 1], ], 'required' => ['intencao', 'confianca'], ], ]], 'tool_choice' => ['type' => 'tool', 'name' => 'registrar_intencao'], 'messages' => [ ['role' => 'user', 'content' => $this->mascararDadosSensiveis($mensagem)], ], ]); $bloco = collect($resposta['content'] ?? [])->firstWhere('type', 'tool_use'); if (! $bloco) { throw new UnexpectedValueException('Resposta do modelo sem bloco tool_use.'); } return Validator::make($bloco['input'], [ 'intencao' => ['required', Rule::in(self::INTENCOES)], 'data_mencionada' => ['nullable', 'date_format:Y-m-d'], 'confianca' => ['required', 'numeric', 'between:0,1'], ])->validate(); } private function mascararDadosSensiveis(string $texto): string { return preg_replace('/\d{3}\.?\d{3}\.?\d{3}-?\d{2}/', '[CPF]', $texto); } }
O JSON Schema da ferramenta ajuda muito, mas eu não confio nele sozinho. A validação com Validator é a última barreira antes de o dado virar um agendamento ou um cancelamento no banco. E a máscara de CPF existe porque cliente manda documento no WhatsApp o tempo todo, e esse dado não precisa sair do seu servidor para classificar uma intenção. Isso é LGPD aplicada no código, e dá para testar, como você vai ver no passo 5.
4. Como gravar respostas reais da API como fixtures?
Fixture é um arquivo com dados fixos usados no teste. Em vez de escrever a resposta da API à mão (e errar o formato), eu gravo uma resposta real uma vez e reaproveito. O Laravel dispara o evento ResponseReceived a cada resposta do client HTTP, então dá para capturar sem mexer no ClaudeClient.
Em routes/console.php:
use App\Services\Llm\ClassificadorDeMensagem; use Illuminate\Http\Client\Events\ResponseReceived; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\File; Artisan::command('llm:gravar {fixture} {mensagem}', function (ClassificadorDeMensagem $classificador) { $caminho = base_path('tests/Fixtures/anthropic/'.$this->argument('fixture').'.json'); Event::listen(ResponseReceived::class, function (ResponseReceived $event) use ($caminho) { if (str_contains($event->request->url(), 'api.anthropic.com') && $event->response->successful()) { File::ensureDirectoryExists(dirname($caminho)); File::put($caminho, json_encode( $event->response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE )); } }); $this->info(json_encode($classificador->classificar($this->argument('mensagem')), JSON_UNESCAPED_UNICODE)); $this->comment("Fixture salva em {$caminho}"); })->purpose('Chama a API real e grava a resposta como fixture de teste');
Rode com a chave real no .env:
php artisan llm:gravar agendar "Oi, quero marcar um corte sexta às 15h" php artisan llm:gravar cancelar "Não vou conseguir ir amanhã, pode cancelar?"
O arquivo gerado tem o formato real da Messages API, mais ou menos assim:
{ "id": "msg_...", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [ { "type": "tool_use", "id": "toolu_...", "name": "registrar_intencao", "input": { "intencao": "agendar", "data_mencionada": null, "confianca": 0.9 } } ], "stop_reason": "tool_use", "usage": { "input_tokens": 0, "output_tokens": 0 } }
Versione as fixtures no Git. Quando mudar o prompt, o schema ou o modelo, regrave. O diff do arquivo mostra o que mudou na resposta.
5. Escrever os testes com Http::fake e preventStrayRequests
Http::fake substitui as respostas do client HTTP do Laravel. Http::preventStrayRequests faz qualquer requisição sem fake lançar exceção, o que impede que um teste esquecido chame a API real no CI.
<?php namespace Tests\Feature\Llm; use App\Services\Llm\ClassificadorDeMensagem; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Tests\TestCase; class ClassificadorDeMensagemTest extends TestCase { protected function setUp(): void { parent::setUp(); Http::preventStrayRequests(); } private function fixture(string $nome): array { return json_decode( file_get_contents(base_path("tests/Fixtures/anthropic/{$nome}.json")), true ); } public function test_classifica_pedido_de_agendamento(): void { Http::fake(['api.anthropic.com/*' => Http::response($this->fixture('agendar'))]); $resultado = app(ClassificadorDeMensagem::class) ->classificar('Oi, quero marcar um corte sexta às 15h'); $this->assertSame('agendar', $resultado['intencao']); } public function test_envia_payload_esperado_e_mascara_cpf(): void { Http::fake(['api.anthropic.com/*' => Http::response($this->fixture('cancelar'))]); app(ClassificadorDeMensagem::class) ->classificar('Meu CPF é 123.456.789-09, quero cancelar'); Http::assertSent(function (Request $request) { $conteudo = $request['messages'][0]['content']; return $request->url() === 'https://api.anthropic.com/v1/messages' && $request->hasHeader('anthropic-version', '2023-06-01') && $request['tool_choice']['name'] === 'registrar_intencao' && str_contains($conteudo, '[CPF]') && ! str_contains($conteudo, '123.456.789-09'); }); } }
Repare no que o segundo teste verifica: o contrato com a API e a regra de privacidade. Ele não confere o texto do prompt palavra por palavra. Comparar o system inteiro com assertSame gera teste que quebra a cada ajuste de vírgula no prompt e não pega bug nenhum.
6. Como testar timeout, erro 529 e resposta inválida do LLM?
As falhas são onde o código com LLM mais quebra em produção, e são as mais fáceis de simular. Http::sequence() devolve respostas diferentes a cada chamada:
use Illuminate\Http\Client\RequestException; use Illuminate\Validation\ValidationException; public function test_repete_quando_api_esta_sobrecarregada(): void { Http::fake(['api.anthropic.com/*' => Http::sequence() ->push(['type' => 'error', 'error' => ['type' => 'overloaded_error', 'message' => 'Overloaded']], 529) ->push($this->fixture('agendar'), 200), ]); $resultado = app(ClassificadorDeMensagem::class)->classificar('Quero marcar horário'); $this->assertSame('agendar', $resultado['intencao']); Http::assertSentCount(2); } public function test_nao_repete_erro_400(): void { Http::fake(['api.anthropic.com/*' => Http::response( ['type' => 'error', 'error' => ['type' => 'invalid_request_error', 'message' => 'bad']], 400 )]); try { app(ClassificadorDeMensagem::class)->classificar('Oi'); $this->fail('Deveria ter lançado RequestException'); } catch (RequestException $e) { Http::assertSentCount(1); } } public function test_rejeita_intencao_fora_do_enum(): void { $resposta = $this->fixture('agendar'); $resposta['content'][0]['input']['intencao'] = 'reembolso'; Http::fake(['api.anthropic.com/*' => Http::response($resposta)]); $this->expectException(ValidationException::class); app(ClassificadorDeMensagem::class)->classificar('Quero meu dinheiro de volta'); }
O último teste é o que mais vale. Ele parte de uma resposta real e muda só o campo que quero quebrar. É assim que eu simulo o modelo "inventando" um valor. Se esse classificador roda dentro de um job disparado por webhook do WhatsApp, a exceção vai para o tratamento de falha do job em vez de virar uma ação errada no sistema.
Rode tudo:
php artisan test --filter=ClassificadorDeMensagemTest
7. Como criar evals para medir a qualidade do prompt?
Eval é um teste de qualidade: um dataset de entradas com a saída esperada, rodado contra o modelo real, que produz uma taxa de acerto. Ele responde à pergunta que o fake não responde: "se eu trocar o modelo ou mexer no prompt, o classificador piora?".
Crie tests/Evals/classificador.json com casos tirados de conversas reais, anonimizadas:
[ { "mensagem": "consegue me encaixar amanhã de manhã?", "esperado": "agendar" }, { "mensagem": "preciso mudar meu horário de quinta pra sexta", "esperado": "remarcar" }, { "mensagem": "não vou poder ir, desculpa", "esperado": "cancelar" }, { "mensagem": "fiz o pix, vocês receberam?", "esperado": "pagamento" }, { "mensagem": "vocês abrem no feriado?", "esperado": "outro" } ]
E o comando em routes/console.php:
Artisan::command('llm:eval {--min=0.9}', function (ClassificadorDeMensagem $classificador) { $casos = json_decode(File::get(base_path('tests/Evals/classificador.json')), true); $falhas = []; foreach ($casos as $caso) { try { $obtido = $classificador->classificar($caso['mensagem'])['intencao']; } catch (Throwable $e) { $obtido = 'erro: '.class_basename($e); } if ($obtido !== $caso['esperado']) { $falhas[] = [$caso['mensagem'], $caso['esperado'], $obtido]; } } $acerto = 1 - count($falhas) / count($casos); if ($falhas) { $this->table(['Mensagem', 'Esperado', 'Obtido'], $falhas); } $this->info(sprintf('Acerto: %.1f%% em %d casos', $acerto * 100, count($casos))); return $acerto >= (float) $this->option('min') ? 0 : 1; })->purpose('Roda o dataset de avaliação contra a API real');
O comando retorna código de saída 1 abaixo do mínimo, então dá para usá-lo num job de CI manual ou agendado. Eu rodo evals quando mexo no prompt, quando avalio trocar de modelo (inclusive para um mais barato) e sempre que um caso errado aparece em produção. Esse caso entra no dataset antes da correção, igual a teste de regressão. A mesma estrutura serve para avaliar recuperação de contexto em RAG com pgvector no Laravel: troque "intenção esperada" por "documento que deveria ter sido recuperado".
Erros comuns
- Teste chamando a API real no CI. Acontece quando falta o fake em um teste ou o padrão de URL não bate. Resolva com
Http::preventStrayRequests()nosetUp(ou noTestCasebase) e a chavefake-keynophpunit.xml. Com chave inválida, um vazamento falha com 401 em vez de gastar crédito. - Testes lentos por causa do retry. O
retrydorme entre as tentativas. SemANTHROPIC_RETRY_SLEEP_MS=0no ambiente de teste, cada teste de 529 espera alguns segundos. - Fake genérico demais.
Http::fake(['*' => ...])responde também para o gateway de pagamento, para a API do WhatsApp e para qualquer outra integração, e mascara bugs. Use o domínio:'api.anthropic.com/*'. - Fixture escrita à mão com formato errado. Esquecer que
contenté uma lista de blocos ou trocarinputporarguments(o nome no formato da OpenAI) gera testes verdes com código quebrado. Grave fixtures da API real com o passo 4. - Fixture desatualizada depois de mudar o schema. Se você adicionou um campo obrigatório na ferramenta, a fixture antiga não tem esse campo. Regrave as fixtures e rode o eval sempre que mexer em
tools. - Eval comparando texto livre. Comparar a resposta em prosa com uma string esperada gera falso negativo o tempo todo. Faça o modelo responder com saída estruturada e compare campos.
$e->responsenulo no callback do retry. Em erro de conexão não existe resposta. Por isso o callback testaConnectionExceptionantes e só lêstatus()quando éRequestException.
Conclusão prática
Se você tem uma integração com LLM sem testes hoje, comece pelo que dá mais retorno com menos esforço. Coloque Http::preventStrayRequests() no TestCase base, grave uma fixture real e escreva o teste de saída inválida do passo 6. Esse trio já pega a maior parte dos bugs que vejo em código com IA: parsing frágil, ausência de validação e chamadas reais escapando para o CI. Os evals vêm depois, com 20 ou 30 casos reais, e passam a ser o portão obrigatório antes de qualquer mudança de prompt ou de modelo entrar em produção.
Perguntas frequentes
O Http::fake funciona com SDKs oficiais da OpenAI ou da Anthropic?
Só se o SDK usar o client HTTP do Laravel por baixo, o que normalmente não acontece. SDKs costumam ter cliente próprio (geralmente Guzzle configurado internamente), e o Http::fake não intercepta essas chamadas. Por isso prefiro o Http:: direto ou um wrapper próprio que eu possa trocar no container nos testes.
Devo rodar evals em todo push?
Não. Evals chamam a API real, custam tokens e podem variar entre execuções. Rode manualmente ao mudar prompt, schema ou modelo, ou de forma agendada. Os testes com fake, que são determinísticos e de graça, rodam em todo push.
Qual percentual mínimo de acerto usar no eval?
Depende do custo do erro. Classificar errado uma dúvida de horário de funcionamento é barato. Cancelar um agendamento por engano não é. Defina o mínimo por caso de uso e, para intenções que executam ações destrutivas, combine o eval com uma confirmação explícita do usuário.
Como lidar com a resposta variando entre execuções no eval?
Use saída estruturada com enum para limitar as respostas possíveis e compare só os campos relevantes. Se um caso alterna entre certo e errado, normalmente a mensagem é ambígua de verdade. Nesse caso, ajuste o prompt com um exemplo ou aceite mais de uma resposta para aquele caso.
Isso funciona para GPT ou Gemini?
Sim. Muda o formato do payload e da resposta, mas a estrutura é a mesma: um cliente isolado, fixtures gravadas da API real, Http::fake com o domínio do provedor e um dataset de eval. Inclusive, o mesmo dataset serve para comparar provedores lado a lado.