Para testar código que chama o Claude no Laravel sem depender da API, sem gastar tokens e sem testes instáveis, você combina três camadas. A primeira é Http::fake com respostas montadas à mão para a lógica de parsing e de erro. A segunda são respostas reais gravadas uma vez em arquivos JSON, com um hash do payload que acusa quando o prompt mudou. A terceira é um grupo pequeno de testes ao vivo, que roda fora do CI principal. O que segura tudo isso é um ponto só: a chamada HTTP fica isolada em uma classe, e o teste nunca sai para a internet sem você pedir.
Comecei a fazer assim depois de ver o mesmo padrão se repetir em integrações com LLM. O teste passa porque alguém mockou a classe inteira, o prompt muda, o formato da resposta muda, e ninguém percebe até o atendimento no WhatsApp começar a classificar tudo como "outro".
O que você vai ter funcionando no final
- Um
ClaudeClientque chama a Messages API da Anthropic viaHttp::do Laravel, com timeout e retry. - Um classificador de intenção de mensagens (agendar, remarcar, cancelar, pagamento) que usa tool calling para devolver saída estruturada.
- Testes unitários com
Http::fake, verificando o que foi enviado e o que foi interpretado. - Testes de falha: 529 (API sobrecarregada), 400 sem retry e queda de conexão.
- Um comando
php artisan claude:gravarque grava respostas reais como fixtures. - Um teste que falha sozinho quando o prompt ou o schema mudam e a fixture ficou velha.
- Um grupo
llm-livede testes contra a API real, separado do CI do dia a dia.
Pré-requisitos:
- PHP 8.2+ e Laravel 11 ou 12.
- Pest (os exemplos usam Pest, mas tudo funciona em PHPUnit com
$this->). - Uma chave da API da Anthropic (
ANTHROPIC_API_KEY), necessária só para gravar fixtures e rodar os testes ao vivo. - Nenhum SDK. A chamada é HTTP puro, o que deixa o fake trivial.
1. Como configurar a chave e os parâmetros do Claude no Laravel?
Tudo que muda entre ambiente de produção e de teste vai para config/services.php. O detalhe importante é o retry_sleep_ms: em teste ele vale zero, senão cada teste de retry dorme de verdade.
// config/services.php 'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'base_url' => env('ANTHROPIC_BASE_URL', 'https://api.anthropic.com'), 'version' => '2023-06-01', 'model' => env('ANTHROPIC_MODEL', 'claude-haiku-4-5'), 'timeout' => env('ANTHROPIC_TIMEOUT', 30), 'retry_sleep_ms' => env('ANTHROPIC_RETRY_SLEEP_MS', 500), ],
# .env ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-haiku-4-5
No phpunit.xml, sobrescreva o que precisa ser previsível:
<env name="ANTHROPIC_API_KEY" value="test-key"/> <env name="ANTHROPIC_RETRY_SLEEP_MS" value="0"/>
2. Como isolar a chamada ao Claude em uma classe testável?
O ClaudeClient só sabe falar HTTP com a API. Não conhece prompt nem regra de negócio. Ele faz retry em erro de conexão, 429 (rate limit), 500 e 529 (overloaded_error, quando a API está sobrecarregada). Erros 4xx de payload, como o 400, sobem direto, porque repetir a mesma requisição inválida só gasta tempo.
<?php namespace App\Ai; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Throwable; class ClaudeClient { public function messages(array $payload): array { $config = config('services.anthropic'); return Http::baseUrl($config['base_url']) ->withHeaders([ 'x-api-key' => $config['key'], 'anthropic-version' => $config['version'], ]) ->acceptJson() ->asJson() ->timeout((int) $config['timeout']) ->retry(3, (int) $config['retry_sleep_ms'], function (Throwable $e) { if ($e instanceof ConnectionException) { return true; } return $e instanceof RequestException && in_array($e->response->status(), [429, 500, 529], true); }) ->post('/v1/messages', $payload) ->throw() ->json(); } }
Esse retry síncrono serve bem para o exemplo e para os testes. Em produção, chamada de LLM costuma ir para job, com backoff e cuidado para não pagar duas vezes pela mesma resposta. Detalhei isso em como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo.
3. Como criar o classificador com tool calling e saída estruturada?
Tool calling é o recurso em que você descreve ferramentas com um JSON Schema e o modelo responde com um bloco tool_use contendo os argumentos. Com tool_choice forçando uma ferramenta específica, isso vira um jeito confiável de obter JSON estruturado.
Repare que o payload é montado em um método público separado, o payload(). É ele que permite gravar fixtures e comparar hashes nos passos seguintes.
<?php namespace App\Ai; class IntentClassifier { public const TOOL = 'registrar_intencao'; public function __construct(private ClaudeClient $claude) {} public function payload(string $mensagem): array { return [ 'model' => config('services.anthropic.model'), 'max_tokens' => 300, 'system' => file_get_contents(resource_path('prompts/intencao.md')), 'tools' => [[ 'name' => self::TOOL, 'description' => 'Registra a intenção da mensagem do cliente.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'intencao' => [ 'type' => 'string', 'enum' => ['agendar', 'remarcar', 'cancelar', 'pagamento', 'outro'], ], 'confianca' => ['type' => 'number'], ], 'required' => ['intencao', 'confianca'], ], ]], 'tool_choice' => ['type' => 'tool', 'name' => self::TOOL], 'messages' => [ ['role' => 'user', 'content' => $mensagem], ], ]; } public function classify(string $mensagem): Intencao { $resposta = $this->claude->messages($this->payload($mensagem)); $bloco = collect($resposta['content'] ?? [])->firstWhere('type', 'tool_use'); if (! $bloco || ($bloco['name'] ?? null) !== self::TOOL) { throw new RespostaInesperada('Claude não chamou a ferramenta esperada.'); } return Intencao::fromArray($bloco['input'] ?? []); } }
O DTO valida o que voltou. O modelo pode respeitar o schema 99% das vezes, mas o código precisa tratar o 1% restante:
<?php namespace App\Ai; enum IntencaoTipo: string { case Agendar = 'agendar'; case Remarcar = 'remarcar'; case Cancelar = 'cancelar'; case Pagamento = 'pagamento'; case Outro = 'outro'; } final class Intencao { public function __construct( public readonly IntencaoTipo $tipo, public readonly float $confianca, ) {} public static function fromArray(array $input): self { $tipo = IntencaoTipo::tryFrom($input['intencao'] ?? '') ?? throw new RespostaInesperada('Intenção fora do enum.'); $confianca = max(0.0, min(1.0, (float) ($input['confianca'] ?? 0))); return new self($tipo, $confianca); } }
<?php namespace App\Ai; class RespostaInesperada extends \RuntimeException {}
Se o seu caso exige validação mais rica, com pedido de correção ao modelo, vale ler como validar o JSON do Claude no Laravel e pedir correção quando a validação falha. Aqui o foco é testar.
4. Como impedir que os testes chamem a API de verdade?
O primeiro teste que vaza para a API real é o que estoura a cota no CI, ou o que passa no seu computador e falha no do colega sem chave. Http::preventStrayRequests() faz qualquer requisição sem fake correspondente lançar exceção. Coloque no TestCase base:
<?php namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\Http; abstract class TestCase extends BaseTestCase { protected function setUp(): void { parent::setUp(); Http::preventStrayRequests(); } }
5. Como testar a chamada ao Claude com Http::fake?
Crie um helper que monte uma resposta no formato real da Messages API. Fica em tests/Support/claude.php, carregado pelo composer.json em autoload-dev.files ou por um require no tests/Pest.php.
<?php function claudeToolUse(string $tool, array $input): array { return [ 'id' => 'msg_test', 'type' => 'message', 'role' => 'assistant', 'model' => 'claude-haiku-4-5', 'content' => [[ 'type' => 'tool_use', 'id' => 'toolu_test', 'name' => $tool, 'input' => $input, ]], 'stop_reason' => 'tool_use', 'stop_sequence' => null, 'usage' => ['input_tokens' => 120, 'output_tokens' => 20], ]; }
Agora o teste. Ele verifica duas coisas: se a resposta foi interpretada certo e se a requisição saiu com os campos que importam.
<?php use App\Ai\IntencaoTipo; use App\Ai\IntentClassifier; use App\Ai\RespostaInesperada; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; it('classifica pedido de remarcação', function () { Http::fake([ 'api.anthropic.com/*' => Http::response( claudeToolUse(IntentClassifier::TOOL, ['intencao' => 'remarcar', 'confianca' => 0.92]) ), ]); $mensagem = 'consigo passar meu horário de amanhã pra sexta?'; $intencao = app(IntentClassifier::class)->classify($mensagem); expect($intencao->tipo)->toBe(IntencaoTipo::Remarcar) ->and($intencao->confianca)->toBe(0.92); Http::assertSent(function (Request $request) use ($mensagem) { return $request->url() === 'https://api.anthropic.com/v1/messages' && $request->hasHeader('anthropic-version', '2023-06-01') && $request['tool_choice']['name'] === IntentClassifier::TOOL && $request['messages'][0]['content'] === $mensagem; }); }); it('falha quando o modelo responde texto em vez de chamar a ferramenta', function () { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'content' => [['type' => 'text', 'text' => 'Acho que é remarcação.']], 'stop_reason' => 'end_turn', ]), ]); app(IntentClassifier::class)->classify('oi'); })->throws(RespostaInesperada::class); it('rejeita intenção fora do enum', function () { Http::fake([ 'api.anthropic.com/*' => Http::response( claudeToolUse(IntentClassifier::TOOL, ['intencao' => 'reclamar', 'confianca' => 0.8]) ), ]); app(IntentClassifier::class)->classify('isso é um absurdo'); })->throws(RespostaInesperada::class);
Um ponto de design: não faço assert do texto exato do system prompt. Isso transforma cada ajuste de vírgula no prompt em teste quebrado. A detecção de prompt alterado fica com as fixtures gravadas, no passo 7, onde ela é útil.
6. Como testar retry, erro 529 e timeout da API?
É aqui que o fake mais compensa, porque é muito difícil reproduzir uma API sobrecarregada sob demanda. Http::sequence() devolve respostas diferentes a cada chamada:
<?php use App\Ai\ClaudeClient; use App\Ai\IntentClassifier; use GuzzleHttp\Exception\ConnectException; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Request; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; it('repete a chamada quando a API responde 529', function () { Http::fake([ 'api.anthropic.com/*' => Http::sequence() ->push(['type' => 'error', 'error' => ['type' => 'overloaded_error', 'message' => 'Overloaded']], 529) ->push(claudeToolUse(IntentClassifier::TOOL, ['intencao' => 'agendar', 'confianca' => 0.9])), ]); $intencao = app(IntentClassifier::class)->classify('quero marcar um corte'); expect($intencao->tipo->value)->toBe('agendar'); Http::assertSentCount(2); }); it('não repete erro 400', function () { Http::fake([ 'api.anthropic.com/*' => Http::response( ['type' => 'error', 'error' => ['type' => 'invalid_request_error', 'message' => 'max_tokens: required']], 400 ), ]); expect(fn () => app(ClaudeClient::class)->messages(['model' => 'x'])) ->toThrow(RequestException::class); Http::assertSentCount(1); }); it('tenta 3 vezes quando a conexão cai', function () { $tentativas = 0; Http::fake(function (Request $request) use (&$tentativas) { $tentativas++; throw new ConnectException('cURL error 28: timeout', $request->toPsrRequest()); }); expect(fn () => app(ClaudeClient::class)->messages(['model' => 'x'])) ->toThrow(ConnectionException::class); expect($tentativas)->toBe(3); });
O último teste lança a ConnectException do Guzzle dentro do fake. O Laravel converte essa exceção na ConnectionException dele, que é o mesmo caminho de um timeout real. O contador em closure confirma o número de tentativas sem depender de como o fake registra requisições que falharam.
7. Como gravar respostas reais do Claude como fixtures?
Resposta montada à mão testa o seu parser, mas não testa o mundo real. Ela não mostra se o modelo de fato classifica "já paguei o Pix, chegou?" como pagamento, nem se a resposta vem no formato que você imagina. A fixture gravada resolve isso. Você chama a API uma vez, salva a resposta em JSON no repositório e reutiliza nos testes, de graça e de forma determinística.
O cuidado extra é guardar junto um hash do payload enviado. Se alguém mudar o prompt, o schema ou o modelo, o hash muda e o teste avisa que a fixture ficou desatualizada, em vez de continuar passando com uma resposta que não corresponde mais ao que o código envia.
<?php namespace App\Ai; final class PayloadHash { public static function of(array $payload): string { return hash('sha256', json_encode($payload)); } }
O comando de gravação:
<?php namespace App\Console\Commands; use App\Ai\ClaudeClient; use App\Ai\IntentClassifier; use App\Ai\PayloadHash; use Illuminate\Console\Command; class GravarFixtureClaude extends Command { protected $signature = 'claude:gravar {nome} {mensagem}'; protected $description = 'Grava uma resposta real do Claude como fixture de teste'; public function handle(IntentClassifier $classifier, ClaudeClient $claude): int { if (app()->isProduction()) { $this->error('Não grave fixtures em produção.'); return self::FAILURE; } $mensagem = $this->argument('mensagem'); $payload = $classifier->payload($mensagem); $resposta = $claude->messages($payload); $dir = base_path('tests/Fixtures/claude'); if (! is_dir($dir)) { mkdir($dir, 0755, true); } file_put_contents("{$dir}/{$this->argument('nome')}.json", json_encode([ 'mensagem' => $mensagem, 'payload_hash' => PayloadHash::of($payload), 'response' => $resposta, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $this->info('Fixture gravada. Confira a resposta antes de commitar.'); return self::SUCCESS; } }
php artisan claude:gravar remarcar "consigo passar meu horário de amanhã pra sexta?" php artisan claude:gravar pagamento-pix "já mandei o pix, caiu aí?"
O helper que carrega a fixture e confere o hash:
<?php use App\Ai\PayloadHash; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; function fakeClaudeFixture(string $nome): array { $fixture = json_decode( file_get_contents(base_path("tests/Fixtures/claude/{$nome}.json")), true, flags: JSON_THROW_ON_ERROR ); Http::fake(function (Request $request) use ($fixture, $nome) { if (PayloadHash::of($request->data()) !== $fixture['payload_hash']) { throw new RuntimeException( "Fixture '{$nome}' desatualizada: o payload mudou. Regrave com php artisan claude:gravar." ); } return Http::response($fixture['response']); }); return $fixture; }
E o teste, com dataset do Pest:
<?php use App\Ai\IntencaoTipo; use App\Ai\IntentClassifier; it('classifica mensagens reais gravadas', function (string $nome, IntencaoTipo $esperado) { $fixture = fakeClaudeFixture($nome); $intencao = app(IntentClassifier::class)->classify($fixture['mensagem']); expect($intencao->tipo)->toBe($esperado); })->with([ ['remarcar', IntencaoTipo::Remarcar], ['pagamento-pix', IntencaoTipo::Pagamento], ]);
Na prática, isso amarra a fixture à versão do prompt. Se você já versiona prompts em arquivo, como descrevi em como versionar prompts no Laravel com rollout gradual e rollback, o PR que altera o prompt passa a trazer junto as fixtures regravadas. O diff das respostas mostra ao revisor o que mudou no comportamento do modelo.
8. Como rodar testes contra a API real sem travar o CI?
Fixture gravada congela a resposta. Ela não pega uma regressão causada pela troca do modelo por trás de um alias. Para isso, mantenha um grupo pequeno de testes ao vivo:
<?php use App\Ai\IntencaoTipo; use App\Ai\IntentClassifier; use Illuminate\Support\Facades\Http; it('classifica cancelamento na API real', function () { if (! env('ANTHROPIC_LIVE_KEY')) { $this->markTestSkipped('Sem chave para testes ao vivo.'); } config(['services.anthropic.key' => env('ANTHROPIC_LIVE_KEY')]); Http::preventStrayRequests(false); $intencao = app(IntentClassifier::class)->classify('não vou conseguir ir, pode desmarcar'); expect($intencao->tipo)->toBe(IntencaoTipo::Cancelar); })->group('llm-live');
# CI de todo PR php artisan test --exclude-group=llm-live # job agendado (noturno, por exemplo) ANTHROPIC_LIVE_KEY=sk-ant-... php artisan test --group=llm-live
Uso uma variável separada (ANTHROPIC_LIVE_KEY) porque o phpunit.xml já força ANTHROPIC_API_KEY=test-key, e isso evita usar a chave de verdade por engano. Mantenha esse grupo enxuto, com dezenas de casos e não centenas. Se quiser saber quanto ele custa por mês com o modelo escolhido, a calculadora de custo de IA faz a conta em dólar e em real.
Erros comuns
| Problema | Causa | Como resolver |
|---|---|---|
| Teste passa localmente e falha no CI com "Attempted request without a matching fake" | Alguma chamada saiu sem fake. O preventStrayRequests está fazendo o trabalho dele |
Adicione o fake para essa URL. Não desligue a proteção para "fazer passar" |
| Fake não é aplicado e a requisição vaza | Padrão de URL errado, por exemplo https://api.anthropic.com/messages sem o /v1 |
Use 'api.anthropic.com/*'. O Laravel compara com curinga no começo |
| Testes de retry demoram segundos | retry_sleep_ms não foi zerado no ambiente de teste |
Defina ANTHROPIC_RETRY_SLEEP_MS=0 no phpunit.xml |
assertSentCount diferente do esperado |
Cada tentativa do retry conta como uma requisição enviada | Conte as tentativas de propósito: 529 seguido de 200 dá 2 |
| Fixture "desatualizada" sem ninguém ter mexido no prompt | O .env local tem outro ANTHROPIC_MODEL, e o modelo entra no hash |
Grave e rode os testes com o mesmo modelo. Fixe o modelo no phpunit.xml se precisar |
| Fixture com dado real de cliente commitada | Gravação feita com mensagem copiada da produção | Grave só com mensagens sintéticas. Nunca use CPF, telefone ou nome real (LGPD) |
| Mock da classe inteira com Mockery e teste sempre verde | O mock devolve o DTO pronto e o parsing nunca é exercitado | Faça o fake na borda HTTP, não no seu próprio código |
Conclusão prática
Se você vai fazer uma coisa só depois de ler isto, coloque Http::preventStrayRequests() no TestCase e mova o fake para a borda HTTP. Isso já elimina o teste que vaza para a API e o mock que esconde bug de parsing. Depois, grave fixtures reais para os cinco ou dez casos que mais importam para o negócio, e deixe o hash do payload obrigar quem mexer no prompt a regravar e revisar o diff. Os testes ao vivo entram por último, num job agendado, como alarme para mudanças no modelo. Com essa ordem, o custo de testar fica perto de zero e o prompt deixa de ser a parte do sistema que ninguém tem coragem de alterar.
Perguntas frequentes
Não é mais simples mockar o ClaudeClient com Mockery?
É mais simples e testa menos. Mockando o client, você pula a montagem do payload, os headers, o retry e o parsing do bloco tool_use. Mock do client só faz sentido em testes de camadas acima, como um controller que usa o classificador.
Preciso da chave da Anthropic no CI?
Não, para o CI de PR. Fakes e fixtures rodam sem chave. A chave só é necessária para gravar fixtures, na sua máquina, e para o job do grupo llm-live.
Como testar streaming com Server-Sent Events?
Separe o parser de eventos da chamada HTTP. Grave o corpo bruto do stream (as linhas event: e data:) em um arquivo e teste o parser contra esse texto. O transporte em si você testa com um ou dois casos ao vivo.
De quanto em quanto tempo devo regravar as fixtures?
Sempre que o hash acusar mudança, ou seja, quando prompt, schema ou modelo mudarem. Fora isso, fixture estável é o objetivo. Quem detecta mudança de comportamento do modelo é o grupo ao vivo, não a regravação periódica.
Fixture gravada substitui avaliação de qualidade com eval?
Não. Fixture garante que o código continua interpretando bem uma resposta conhecida. Eval mede se o modelo acerta em um conjunto grande de casos. As duas coisas são complementares e rodam em momentos diferentes.