# Como testar código que chama o Claude no Laravel com Http::fake e respostas gravadas

> Passo a passo para testar integrações com LLM no Laravel sem gastar tokens: Http::fake, fixtures gravadas da API real, detecção de prompt alterado e testes de retry.

Por Lucas Roberto · IA · publicado em 2026-10-29 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/como-testar-codigo-que-chama-o-claude-no-laravel-com-httpfake-e-respostas-gravadas

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.

```php
// 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),
],
```

```dotenv
# .env
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-haiku-4-5
```

No `phpunit.xml`, sobrescreva o que precisa ser previsível:

```xml
<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
<?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](/blog/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
<?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
<?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
<?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](/blog/como-validar-o-json-do-claude-no-laravel-e-pedir-correcao-quando-a-validacao-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
<?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
<?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
<?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
<?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
<?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
<?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;
    }
}
```

```bash
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
<?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
<?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](/blog/como-versionar-prompts-no-laravel-arquivos-no-repositorio-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
<?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');
```

```bash
# 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](/ferramentas/calculadora-custo-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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
