Disciplina de Testes com Pest
Funcionalidades enviadas sem teste — ou "testadas" com suítes excessivamente simuladas que afirmam detalhes de implementação — quebram silenciosamente em produção e apodrecem a cada refatoração. Esta habilidade faz você escrever testes Pest 3/4 que provam comportamento, cobrem o caminho infeliz e autorização, e sobrevivem a refatorações.
A armadilha
Dois modos de falha, ambos caros:
- Sem testes / testes apenas do caminho feliz. A funcionalidade funciona na demonstração, então em produção um convidado acessa o endpoint, um inquilino lê dados de outro inquilino, uma lacuna de validação deixa entrar lixo, ou um 404 vira um 500. Falhas de autorização (caminhos
403/404que nunca foram afirmados) são como vazamentos de dados chegam à produção. - Testes quebradiços e excessivamente simulados. Você simula seu próprio
UserRepository/serviço comshouldReceive('save')->once(), então o teste passa mesmo quando a consulta real está errada, e explode no momento em que alguém renomeia um método. A suíte está verde, dá falsa confiança, e é deletada por frustração durante a próxima refatoração — deixando você de volta ao modo 1.
A correção é a mesma disciplina em ambos os casos: finja os limites que você não possui, exercite o código que você possui, e afirme resultados observáveis (linhas do banco de dados, status HTTP, jobs/mails despachados) — incluindo os resultados de falha e autorização.
Regras
- Escreva o teste com a funcionalidade, não "depois". Uma alteração em controller/job/command/policy está incompleta até que tenha um teste Pest. Nunca relate uma funcionalidade como concluída sem um teste correspondente que afirme seu comportamento.
- Use expressões idiomáticas do Pest. Defina casos com
it('...')outest('...'); afirme com o fluenteexpect(...); compartilhe configuração combeforeEach(). Não escreva PHPUnitclass FooTest extends TestCasea menos que o arquivo já use esse estilo. - Escolha o tipo certo. Comportamento HTTP/ponta a ponta → Teste de Funcionalidade (inicializa o framework, acessa rotas via
$this->get/post/...). Lógica pura sem framework/BD → Teste Unitário. Padrão: Teste de Funcionalidade quando em dúvida — ele pega mais. - Redefina o banco de dados com uma trait, via
uses(). ApliqueRefreshDatabase(envolvido em transação) ouLazilyRefreshDatabase(migra apenas quando um teste toca o BD) emtests/Pest.phppara todo o diretórioFeature, ouuses(RefreshDatabase::class)no topo de um arquivo. NUNCA deixe testes de BD dependentes de estado residual. - Construa dados com fábricas, nunca inserções manuais. Use
User::factory()->create()/->make()e fábricas de relacionamentos. Construções manuaisDB::table()->insert([...])ounew Model([...])->save()em testes são frágeis e pulam casts/padrões — proíba isso. - Desduplique com datasets, não copie e cole. Quando a mesma afirmação é executada sobre muitas entradas (cargas válidas/inválidas, matrizes de papéis), use
->with([...])(inline ou um dataset nomeado emtests/Datasets/) em vez de N testes quase idênticos. - Finja os limites que você não possui. Para qualquer coisa externa ou assíncrona, use os fakes das fachadas antes da ação:
Http::fake(),Queue::fake(),Bus::fake(),Mail::fake(),Notification::fake(),Event::fake(),Storage::fake(). Para tempo, use$this->travel(...),$this->travelTo(...), ou$this->freezeTime()— nunca afirme contra umnow()não congelado. - NÃO simule seus próprios modelos/serviços até perder o sentido. Evite
Mockery::mock(MyService::class)->shouldReceive(...)eModel::shouldReceive(...)para código sob teste. Deixe o código real rodar contra o BD de teste e os limites falsificados. Simule sua própria classe apenas em uma costura genuína que você não está exercitando (por exemplo, um wrapper lento de SDK de terceiros). - Afirme resultados, não detalhes internos. Prefira
assertDatabaseHas/assertDatabaseMissing/assertModelExists,assertStatus/assertOk/assertCreated,assertJson/assertJsonPath,assertRedirect,Mail::assertSent,Notification::assertSentTo,Queue::assertPushed,Bus::assertDispatched,Storage::disk()->assertExists. Não afirme "método X foi chamado uma vez" como proxy para comportamento. - Sempre teste o caminho infeliz E a autorização. Cada endpoint/ação precisa, no mínimo: o caminho feliz, um caminho de validação/falha, e os caminhos de autenticação — convidado →
401/redirecionamento, usuário errado/papel →403, recurso ausente/estrangeiro →404. Para qualquer funcionalidade sensível à segurança, adicione um teste explícito de "outro usuário não pode acessar/modificar isso". Isso é inegociável para qualquer coisa que toque em propriedade ou inquilinato. - Adicione testes de arquitetura como guardrails globais baratos. Mantenha um
tests/Arch.php(ou casos arch emPest.php) com os presets que se encaixam:arch()->preset()->php()(proíbe saída de debugdie/var_dump/dde funções PHP obsoletas),arch()->preset()->security()(proíbeeval,extract,unserialize, hashing fraco/md5/sha1, aleatoriedade insegura, etc.), earch()->preset()->laravel()(impõe convenções do Laravel — incluindo nenhumenv()fora da config). Adicione invariantes do projeto comoarch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller'). - Um comportamento por teste, nome descritivo.
it('bloqueia convidados do painel'), nãoit('funciona'). Se um nome de teste precisar de "e", divida-o. - Portão de cobertura no CI. Execute
./vendor/bin/pest --coverage --min=NNpara impor um mínimo, e./vendor/bin/pest --type-coverage(requer o plugin type-coverage) para impor assinaturas tipadas. Trate novas linhas não testadas como um defeito. - Browser/E2E com moderação (Pest 4). O Pest 4 oferece testes de navegador via
visit('/')->...->assertSee(...)(baseado em Playwright). Reserve-o para fluxos full-stack/JS verdadeiros; não reimplemente asserções de camada HTTP como testes de navegador lentos.
Bom vs ruim
// tests/Feature/UpdatePostTest.php
use App\Models\Post;
use App\Models\User;
// ❌ excessivamente simulado: testa que um método foi chamado, não o que aconteceu.
// Passa mesmo se o post errado for atualizado ou a autenticação estiver faltando.
it('atualiza um post', function () {
$service = Mockery::mock(App\Services\PostService::class);
$service->shouldReceive('update')->once()->andReturnTrue();
$this->app->instance(App\Services\PostService::class, $service);
$this->put('/posts/1', ['title' => 'Novo'])->assertOk();
});
// ✅ exercita código real contra o BD de teste; afirma o resultado E a autorização.
use App\Models\Post;
use App\Models\User;
it('permite que o proprietário atualize seu post', function () {
$owner = User::factory()->create();
$post = Post::factory()->for($owner)->create(['title' => 'Antigo']);
$this->actingAs($owner)
->put("/posts/{$post->id}", ['title' => 'Novo'])
->assertRedirect();
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Novo']);
});
it('proíbe um não proprietário de atualizar o post', function () {
$post = Post::factory()->create(['title' => 'Antigo']);
$attacker = User::factory()->create();
$this->actingAs($attacker)
->put("/posts/{$post->id}", ['title' => 'Hackeado'])
->assertForbidden(); // 403
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Antigo']);
});
it('bloqueia convidados', function () {
$post = Post::factory()->create();
$this->put("/posts/{$post->id}", ['title' => 'Novo'])
->assertRedirect('/login'); // ou assertUnauthorized() para uma API
});
// ❌ acessa a rede real (instável, lento, pode vazar chaves); não afirma nada útil.
it('notifica o slack', function () {
$resp = (new App\Services\Slack)->ping('deploy ok');
expect($resp)->toBeArray();
});
// ✅ finja o limite que você não possui; afirme a requisição que você enviou.
use Illuminate\Support\Facades\Http;
it('envia a mensagem de deploy para o slack', function () {
Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
(new App\Services\Slack)->ping('deploy ok');
Http::assertSent(fn ($request) =>
$request->url() === 'https://hooks.slack.com/services/T/B/x'
&& $request['text'] === 'deploy ok'
);
});
// ❌ testes de validação quase idênticos copiados e colados.
it('rejeita email vazio', function () {
$this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('rejeita email ruim', function () {
$this->post('/register', ['email' => 'nope'])->assertSessionHasErrors('email');
});
// ✅ um teste, orientado por tabela com um dataset.
it('rejeita emails inválidos', function (string $email) {
$this->post('/register', ['email' => $email])
->assertSessionHasErrors('email');
})->with([
'vazio' => '',
'sem arroba' => 'nope',
'sem domínio' => 'a@',
]);
// ✅ limites assíncronos + de tempo falsificados; afirme o despacho e o efeito colateral.
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('enfileira um lembrete e dá boas-vindas ao usuário no cadastro', function () {
Queue::fake();
Mail::fake();
$this->freezeTime();
$this->post('/register', [
'name' => 'Ada', 'email' => '[email protected]', 'password' => 'senha-secreta',
])->assertRedirect();
Queue::assertPushed(SendReminder::class);
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php — ✅ guardrails globais baratos
arch()->preset()->php(); // proíbe saída de debug die/var_dump/dd + funções PHP obsoletas
arch()->preset()->security(); // proíbe eval, extract, unserialize, md5/sha1, aleatoriedade fraca, etc.
arch()->preset()->laravel(); // impõe convenções do Laravel, incl. nenhum env() fora da config
arch('controllers estendem o controller base')
->expect('App\Http\Controllers')
->toExtend('App\Http\Controllers\Controller');
arch('models vivem no namespace certo')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
Como verificar
Execute estes comandos antes de declarar uma funcionalidade + seus testes como concluídos:
# 1. A suíte inteira deve estar verde.
./vendor/bin/pest
# 2. Execute apenas o(s) arquivo(s) novo(s)/alterado(s) enquanto itera.
./vendor/bin/pest tests/Feature/UpdatePostTest.php
# 3. Imponha um piso de cobertura (requer Xdebug ou PCOV).
./vendor/bin/pest --coverage --min=80
# 4. Cobertura de tipos (requer pestphp/pest-plugin-type-coverage).
./vendor/bin/pest --type-coverage --min=100
# 5. Estilo permanece limpo.
./vendor/bin/pint --test
Em seguida, verifique se você NÃO reintroduziu a armadilha:
# Cobertura de autorização: cada teste de funcionalidade protegida deve afirmar um caminho 401/403/404.
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\\((401|403|404)\\)" tests/
# Cheiro de over-mocking: simular suas PRÓPRIAS classes de aplicação é uma bandeira vermelha — revise cada ocorrência.
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\\(" tests/
# Inserções manuais em vez de fábricas — substitua por ->factory().
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/
# Limites externos devem ser falsificados — confirme que fakes existem onde o código chama algo externo.
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/
# Os presets de arquitetura security + php (os guardrails globais baratos) devem estar presentes em algum lugar.
grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/
Afirme em testes, não apenas em código: para cada novo endpoint, confirme que há (a) uma afirmação de resultado do caminho feliz (assertDatabaseHas/assertOk/assertJsonPath), (b) pelo menos uma afirmação de falha/validação, e (c) as afirmações de autorização de convidado + usuário errado. Se algum faltar, a funcionalidade não está concluída.
Quando é aceitável flexibilizar a regra
- Simular seu próprio código em uma costura externa genuína. Um wrapper fino em torno de um SDK pago de terceiros (SMS, gateway de pagamento) que você não pode falsificar na camada HTTP é um alvo legítimo de
Mockery— você está isolando a dependência deles, não falsificando o comportamento sob teste. - Testar unitariamente lógica pura sem o BD. Objetos de valor, formatadores e calculadoras não precisam de
RefreshDatabasenem de fábricas — instancie-os diretamente e useexpect()para o resultado; isso é mais rápido e correto. make()em vez decreate()quando um teste nunca persiste —User::factory()->make()evita uma gravação no BD para asserções de lógica pura.- Limiares de cobertura mais baixos em código legado. Defina um
--minrealista e aumente-o gradualmente em vez de bloquear todo o trabalho; nunca deixe "100% ou nada" se tornar uma desculpa para enviar zero testes. - Testes de navegador para fluxos genuinamente orientados a JS (interações Livewire/Inertia/Vue, seletores de arquivos) — aí,
visit()é a ferramenta certa, não um exagero.
Referências
- Pest — Escrevendo Testes, expectativas, hooks: https://pestphp.com/docs/writing-tests
- Pest — Datasets: https://pestphp.com/docs/datasets
- Pest — Testes de Arquitetura & presets (
security,laravel,toExtend): https://pestphp.com/docs/arch-testing - Pest — Cobertura &
--min: https://pestphp.com/docs/coverage - Pest — Plugin Type Coverage: https://pestphp.com/docs/type-coverage
- Pest v4 — Testes de Navegador (
visit(), regressão visual): https://pestphp.com/docs/browser-testing - Laravel — Testes HTTP (afirmações de status/JSON/redirecionamento): https://laravel.com/docs/testing
- Laravel — Testes de Banco de Dados (
RefreshDatabase, fábricas,assertDatabaseHas): https://laravel.com/docs/database-testing - Laravel — Simulação & fakes de fachadas (
Http,Queue,Bus,Mail,Notification,Event,Storage, tempo): https://laravel.com/docs/mocking