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. 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

  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.