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 ClaudeClient que chama a Messages API da Anthropic via Http:: 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:gravar que 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-live de testes contra a API real, separado do CI do dia a dia.

Pré-requisitos:

  1. PHP 8.2+ e Laravel 11 ou 12.
  2. Pest (os exemplos usam Pest, mas tudo funciona em PHPUnit com $this->).
  3. Uma chave da API da Anthropic (ANTHROPIC_API_KEY), necessária só para gravar fixtures e rodar os testes ao vivo.
  4. 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.