# Como testar código que usa LLM no Laravel: Http::fake, respostas gravadas e evals

> Passo a passo para testar integrações com Claude no Laravel sem gastar tokens no CI: Http::fake, fixtures gravadas de respostas reais, testes de falha e evals.

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

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:

1. Um cliente HTTP para a API do Claude (Anthropic) com timeout e retry, isolado numa classe.
2. Um classificador de mensagens de WhatsApp com saída estruturada e validação.
3. Um comando Artisan que grava respostas reais da API como fixtures.
4. 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.
5. Um comando `llm:eval` que 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](/blog/como-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`:

```env
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-5
ANTHROPIC_TIMEOUT=30
ANTHROPIC_RETRY_SLEEP_MS=1000
```

Em `config/services.php`:

```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:

```xml
<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
<?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](/blog/como-extrair-dados-de-comprovantes-em-pdf-com-claude-e-saida-estruturada-no-laravel).

```php
<?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`:

```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`:

```bash
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:

```json
{
  "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
<?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:

```php
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:

```bash
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:

```json
[
  { "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`:

```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](/blog/como-fazer-rag-com-pgvector-no-laravel-embeddings-busca-vetorial-e-respostas-com-claude): troque "intenção esperada" por "documento que deveria ter sido recuperado".

## Erros comuns

1. **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()` no `setUp` (ou no `TestCase` base) e a chave `fake-key` no `phpunit.xml`. Com chave inválida, um vazamento falha com 401 em vez de gastar crédito.
2. **Testes lentos por causa do retry.** O `retry` dorme entre as tentativas. Sem `ANTHROPIC_RETRY_SLEEP_MS=0` no ambiente de teste, cada teste de 529 espera alguns segundos.
3. **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/*'`.
4. **Fixture escrita à mão com formato errado.** Esquecer que `content` é uma lista de blocos ou trocar `input` por `arguments` (o nome no formato da OpenAI) gera testes verdes com código quebrado. Grave fixtures da API real com o passo 4.
5. **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`.
6. **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.
7. **`$e->response` nulo no callback do retry.** Em erro de conexão não existe resposta. Por isso o callback testa `ConnectionException` antes 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.

---

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