Testes no Laravel com Pest

1. Introdução ao Pest PHP

Pest é um framework de testes moderno construído sobre o PHPUnit, projetado para tornar a escrita de testes mais expressiva e agradável. No ecossistema Laravel, o Pest se destaca por sua sintaxe limpa e funções auxiliares que simplificam tarefas comuns de teste.

Diferenças entre Pest e PHPUnit tradicional:

  • Pest usa closures em vez de classes de teste
  • Sintaxe mais concisa com funções test(), it() e describe()
  • Encadeamento de expectativas com expect() e toBe()
  • Menos boilerplate e configuração manual

Instalação no Laravel:

composer require pestphp/pest --dev
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:install

Após a instalação, o arquivo tests/Pest.php será criado automaticamente com configurações básicas.

2. Estrutura de Testes com Pest

A organização segue a mesma estrutura do Laravel: tests/Unit para testes unitários e tests/Feature para testes de funcionalidade.

Funções helpers principais:

// test() - função principal
test('soma dois números', function () {
    $resultado = 2 + 2;
    expect($resultado)->toBe(4);
});

// it() - estilo mais descritivo
it('pode criar um usuário', function () {
    $user = User::factory()->create();
    expect($user)->toBeInstanceOf(User::class);
});

// describe() - agrupa testes relacionados
describe('Operações matemáticas', function () {
    test('adição', function () {
        expect(1 + 1)->toBe(2);
    });

    test('subtração', function () {
        expect(5 - 3)->toBe(2);
    });
});

Arquivo Pest.php:

uses(Tests\TestCase::class)->in('Feature');
uses(Tests\TestCase::class)->in('Unit');

3. Testes de Unidade (Unit Tests)

Testes unitários focam em classes e métodos isolados, sem dependências externas.

Exemplo de teste unitário:

test('calcula preço com desconto', function () {
    $produto = new Produto();
    $produto->preco = 100;

    $precoFinal = $produto->aplicarDesconto(10);

    expect($precoFinal)->toBe(90.0);
});

Uso de mocks e spies:

test('envia email de boas-vindas', function () {
    $mailer = Mockery::mock(Mailer::class);
    $mailer->shouldReceive('send')
           ->once()
           ->with(Mockery::type(WelcomeEmail::class));

    $service = new UserService($mailer);
    $service->registerUser(['name' => 'João']);
});

Expectativas encadeadas:

test('valida dados do usuário', function () {
    $validator = new UserValidator();
    $resultado = $validator->validate([
        'name' => 'Maria',
        'email' => 'maria@example.com'
    ]);

    expect($resultado)
        ->toBeTrue()
        ->and($validator->errors())
        ->toBeEmpty();
});

4. Testes de Funcionalidade (Feature Tests)

Testes de funcionalidade simulam requisições HTTP e verificam respostas completas.

Testando rotas e controllers:

test('lista todos os produtos', function () {
    Produto::factory()->count(3)->create();

    $response = $this->getJson('/api/produtos');

    $response->assertStatus(200)
             ->assertJsonCount(3)
             ->assertJsonStructure([
                 '*' => ['id', 'nome', 'preco']
             ]);
});

test('cria novo produto com sucesso', function () {
    $dados = [
        'nome' => 'Notebook',
        'preco' => 3500.00
    ];

    $response = $this->postJson('/api/produtos', $dados);

    $response->assertStatus(201)
             ->assertJson([
                 'nome' => 'Notebook',
                 'preco' => 3500.00
             ]);

    $this->assertDatabaseHas('produtos', $dados);
});

Simulação de diferentes métodos HTTP:

test('atualiza produto', function () {
    $produto = Produto::factory()->create();

    $response = $this->putJson("/api/produtos/{$produto->id}", [
        'preco' => 4000.00
    ]);

    $response->assertOk();
    expect($produto->fresh()->preco)->toBe(4000.00);
});

test('deleta produto', function () {
    $produto = Produto::factory()->create();

    $response = $this->deleteJson("/api/produtos/{$produto->id}");

    $response->assertNoContent();
    $this->assertDatabaseMissing('produtos', ['id' => $produto->id]);
});

5. Testes com Banco de Dados

Configuração com RefreshDatabase:

<?php

uses(Tests\TestCase::class)
    ->in('Feature')
    ->group('database');

Uso de factories e seeders:

test('relacionamento entre usuário e pedidos', function () {
    $user = User::factory()
        ->has(Pedido::factory()->count(3))
        ->create();

    expect($user->pedidos)->toHaveCount(3);
    expect($user->pedidos->first()->user_id)->toBe($user->id);
});

test('consulta com escopo global', function () {
    Produto::factory()->create(['ativo' => true]);
    Produto::factory()->create(['ativo' => false]);

    $ativos = Produto::ativos()->get();

    expect($ativos)->toHaveCount(1);
});

Testando relacionamentos Eloquent:

test('cria pedido com itens', function () {
    $pedido = Pedido::factory()
        ->has(ItemPedido::factory()->count(2), 'itens')
        ->create();

    expect($pedido->itens)
        ->toHaveCount(2)
        ->each->toBeInstanceOf(ItemPedido::class);

    $this->assertDatabaseCount('itens_pedido', 2);
});

6. Testes de Autenticação e Autorização

Simulação de usuários autenticados:

test('usuário autenticado acessa dashboard', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)
                     ->get('/dashboard');

    $response->assertOk();
});

test('usuário não autenticado é redirecionado', function () {
    $response = $this->get('/dashboard');

    $response->assertRedirect('/login');
});

Testando gates e policies:

test('apenas admin pode deletar usuários', function () {
    $admin = User::factory()->create(['role' => 'admin']);
    $user = User::factory()->create();

    $response = $this->actingAs($admin)
                     ->delete("/api/users/{$user->id}");

    $response->assertOk();

    $this->actingAs(User::factory()->create(['role' => 'user']))
         ->delete("/api/users/{$user->id}")
         ->assertForbidden();
});

Testando middlewares:

test('middleware verifica assinatura ativa', function () {
    $user = User::factory()->create();
    $user->subscription()->create(['active_until' => now()->subDay()]);

    $response = $this->actingAs($user)
                     ->get('/premium-content');

    $response->assertRedirect('/subscription/expired');
});

7. Testes de Filas, Jobs e Notificações

Fake de filas com Queue::fake():

test('job é enviado para fila após registro', function () {
    Queue::fake();

    $this->post('/register', [
        'name' => 'João',
        'email' => 'joao@example.com',
        'password' => 'password123'
    ]);

    Queue::assertPushed(SendWelcomeEmail::class);
    Queue::assertPushed(function (SendWelcomeEmail $job) {
        return $job->user->email === 'joao@example.com';
    });
});

Testando eventos e listeners:

test('evento é disparado ao criar pedido', function () {
    Event::fake();

    $pedido = Pedido::factory()->create();

    Event::assertDispatched(PedidoCriado::class);
    Event::assertDispatched(function (PedidoCriado $event) use ($pedido) {
        return $event->pedido->id === $pedido->id;
    });
});

Testando notificações:

test('notificação é enviada após compra', function () {
    Notification::fake();

    $user = User::factory()->create();
    $this->actingAs($user)->post('/comprar', ['produto_id' => 1]);

    Notification::assertSentTo(
        $user,
        CompraRealizadaNotification::class
    );

    Notification::assertSentTo(
        $user,
        function (CompraRealizadaNotification $notification) {
            return $notification->valor > 0;
        }
    );
});

8. Boas Práticas e Otimização

Organização com describe() e grupos:

describe('API de Produtos', function () {
    beforeEach(function () {
        $this->user = User::factory()->create();
    });

    describe('GET /api/produtos', function () {
        test('retorna lista paginada', function () {
            Produto::factory()->count(15)->create();

            $response = $this->actingAs($this->user)
                             ->getJson('/api/produtos?per_page=10');

            $response->assertJsonCount(10, 'data');
        });
    })->group('api', 'produtos');
});

Uso de datasets para testes parametrizados:

test('valida campos obrigatórios', function ($campo, $valor) {
    $response = $this->postJson('/api/produtos', [
        $campo => $valor
    ]);

    $response->assertStatus(422);
    $response->assertJsonValidationErrors($campo);
})->with([
    ['nome', ''],
    ['preco', null],
    ['preco', -10],
    ['categoria_id', 'invalido']
]);

Cobertura de código e integração com CI/CD:

# Gerar relatório de cobertura
./vendor/bin/pest --coverage

# Executar apenas testes de um grupo
./vendor/bin/pest --group=api

# Integração com GitHub Actions
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
  pest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.1'
      - run: composer install
      - run: php artisan key:generate
      - run: ./vendor/bin/pest

Referências