404 Not Found

404 Not Found


nginx

Testes Automatizados no Laravel

Testes são a "rede de segurança" do Laravel — executá-los toda vez que você faz uma alteração de código garante que bugs sejam detectados imediatamente e não cheguem ao ambiente de produção.

1. O Que Você Vai Aprender


2. Uma História Real de um Desenvolvedor em Plantão Noturno

(1) Dor: Bugs ocorrem toda vez que fazemos deploy

Bob fez deploy de uma nova versão do ShopMetrics na sexta-feira à noite — alterou a lógica de desconto de pedidos, testou manualmente alguns cenários para garantir que tudo estava funcionando e publicou. Acontece que, na segunda-feira de manhã, Alice relatou que descontos negativos haviam causado totais de pedidos negativos, resultando em mais de 100 usuários recebendo seus itens de graça. Charlie disse: "Se você tivesse testes automatizados, esse bug teria sido detectado quando o código fosse submetido."

(2) Soluções para Testes Automatizados

Testes automatizados são executados automaticamente a cada alteração de código — a lógica de desconto é totalmente coberta por testes, e casos de teste para descontos negativos falham imediatamente, garantindo que bugs nunca cheguem à produção.

PHP
// Testar que desconto negativo é rejeitado
test('order rejects negative discount', function () {
    $response = $this->postJson('/api/v1/orders', [
        'items' => [['product_id' => 1, 'quantity' => 1]],
        'discount' => -10,  // Deve ser rejeitado!
    ]);
    $response->assertJsonValidationErrors('discount');
});

(3) Resultado

Depois que Bob adicionou os testes, o bug envolvendo descontos negativos foi detectado durante o desenvolvimento, e Alice nunca mais enfrentou um incidente de "compras grátis".


3. Configuração do Ambiente de Testes

(1) phpunit.xml

XML
<!-- phpunit.xml -->
<phpunit>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>

(2) .env.testing

BASH
# .env.testing — ambiente de teste dedicado
APP_ENV=testing
APP_KEY=base64:test-key-for-testing-only
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array

(3) A Pirâmide de Testes

100%
graph TD
    A["Testes Unitários<br/>(Rápidos, muitos)<br/>Métodos Model, lógica Service"] --> B["Testes Funcionais<br/>(Médios, alguns)<br/>Requisições HTTP, consultas DB"]
    B --> C["Testes API/E2E<br/>(Lentos, poucos)<br/>Ciclo completo de requisição"]
Nível Quantidade Velocidade Conteúdo do Teste
Unitário Muitos Rápido (ms) Lógica Pura, Métodos Model
Funcional Médio Médio (100 ms) Requisições HTTP, operações DB
API/E2E Poucos Lento (s) Cadeia completa de requisição

(1) ▶ Exemplo: Configuração do Ambiente de Testes do ShopMetrics

BASH
# Criar .env.testing
cp .env .env.testing

# Editar .env.testing
# DB_CONNECTION=sqlite
# DB_DATABASE=:memory:
# QUEUE_CONNECTION=sync

# Executar testes
php artisan test           # Todos os testes
php artisan test --parallel  # Paralelo (mais rápido)
php artisan test --coverage  # Com relatório de cobertura

Saída:

TEXT
# Comando executado com sucesso

4. Testes Unitários

(1) PHPUnit vs. Pest

Dimensão PHPUnit Pest
Sintaxe Classe + Método Funcional
Código de Exemplo Mais Menos
Legibilidade Médio ✅ Alta
Compatibilidade 100% Baseado em PHPUnit
Adequado para Testes complexos Simples e rápido

(2) Estilo PHPUnit

PHP
// tests/Unit/Models/ShopTest.php
class ShopTest extends TestCase
{
    public function test_shop_generates_slug(): void
    {
        $shop = Shop::factory()->make(['name' => 'Alice Store']);
        $this->assertEquals('alice-store', Str::slug($shop->name));
    }

    public function test_active_scope_filters_active_shops(): void
    {
        Shop::factory()->create(['status' => 'active']);
        Shop::factory()->create(['status' => 'suspended']);

        $activeShops = Shop::active()->get();

        $this->assertCount(1, $activeShops);
        $this->assertEquals('active', $activeShops->first()->status);
    }

    public function test_revenue_formatted_accessor(): void
    {
        $shop = Shop::factory()->make(['revenue' => 12345.67]);
        $this->assertEquals('$12,345.67', $shop->revenue_formatted);
    }
}

(3) Estilo Pest

PHP
// tests/Unit/Models/ShopTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('generates slug from name', function () {
    $shop = Shop::factory()->make(['name' => 'Alice Store']);
    expect(Str::slug($shop->name))->toBe('alice-store');
});

it('filters active shops via scope', function () {
    Shop::factory()->create(['status' => 'active']);
    Shop::factory()->create(['status' => 'suspended']);

    $active = Shop::active()->get();

    expect($active)->toHaveCount(1);
    expect($active->first()->status)->toBe('active');
});

it('formats revenue with currency symbol', function () {
    $shop = Shop::factory()->make(['revenue' => 12345.67]);
    expect($shop->revenue_formatted)->toBe('$12,345.67');
});

(1) ▶ Exemplo: Testes Unitários de Model do ShopMetrics

PHP
// tests/Unit/Models/OrderTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('calculates order total from items', function () {
    $order = Order::factory()->create();
    $order->items()->createMany([
        ['product_id' => 1, 'quantity' => 2, 'price' => 10.00],
        ['product_id' => 2, 'quantity' => 1, 'price' => 25.00],
    ]);

    $order->updateTotal();

    expect($order->fresh()->total)->toBe(45.00);
});

it('rejects invalid status transition', function () {
    $order = Order::factory()->create(['status' => 'completed']);

    expect(fn () => $order->update(['status' => 'pending']))
        ->toThrow(InvalidArgumentException::class);
});

it('scope completed returns only completed orders', function () {
    Order::factory()->create(['status' => 'completed']);
    Order::factory()->create(['status' => 'pending']);
    Order::factory()->create(['status' => 'cancelled']);

    expect(Order::completed()->count())->toBe(1);
});

Saída:

TEXT
// Execução bem-sucedida

5. Testes Funcionais

(1) Teste de Requisições HTTP

PHP
// tests/Feature/ShopControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('displays shops list on index page', function () {
    $shops = Shop::factory()->count(3)->create();

    $response = $this->get(route('shops.index'));

    $response->assertStatus(200);
    $response->assertViewIs('shops.index');
    foreach ($shops as $shop) {
        $response->assertSee($shop->name);
    }
});

it('creates a shop with valid data', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)->post(route('shops.store'), [
        'name' => 'New Shop',
        'slug' => 'new-shop',
        'description' => 'A test shop',
    ]);

    $response->assertRedirect(route('shops.index'));
    $this->assertDatabaseHas('shops', ['name' => 'New Shop']);
});

it('validates required fields on shop creation', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)->post(route('shops.store'), []);

    $response->assertSessionHasErrors(['name', 'slug']);
});

it('prevents non-owners from creating shops', function () {
    $user = User::factory()->create(['role' => 'analyst']);

    $response = $this->actingAs($user)->post(route('shops.store'), [
        'name' => 'Unauthorized Shop',
        'slug' => 'unauthorized',
    ]);

    $response->assertForbidden();
});

(2) Assertions de Banco de Dados

Método de Assertion Descrição
assertDatabaseHas() Registros existem no banco de dados
assertDatabaseMissing() Nenhum registro encontrado no banco de dados
assertDatabaseCount() Corresponder pelo número de registros
assertSoftDeleted() Registros com soft-delete existem

(1) ▶ Exemplo: Teste Funcional CRUD do ShopMetrics

PHP
// tests/Feature/ProductControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->owner = User::factory()->create(['role' => 'tenant_owner']);
    $this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});

it('lists products for a shop', function () {
    Product::factory()->count(5)->create(['shop_id' => $this->shop->id]);

    $response = $this->actingAs($this->owner)
        ->get(route('shops.products.index', $this->shop));

    $response->assertOk();
    $response->assertViewHas('products');
});

it('stores a new product', function () {
    Storage::fake('public');

    $response = $this->actingAs($this->owner)
        ->post(route('products.store'), [
            'shop_id' => $this->shop->id,
            'name' => 'Widget Pro',
            'sku' => 'WP-001',
            'price' => 49.99,
            'stock' => 100,
        ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('products', ['sku' => 'WP-001']);
});

it('rejects negative price', function () {
    $response = $this->actingAs($this->owner)
        ->post(route('products.store'), [
            'shop_id' => $this->shop->id,
            'name' => 'Bad Product',
            'sku' => 'BP-001',
            'price' => -10,
            'stock' => 50,
        ]);

    $response->assertSessionHasErrors('price');
});

it('deletes a product with soft delete', function () {
    $product = Product::factory()->create(['shop_id' => $this->shop->id]);

    $this->actingAs($this->owner)
        ->delete(route('products.destroy', $product));

    $this->assertSoftDeleted($product);
});

Saída:

TEXT
// Execução bem-sucedida

6. Testes de API

(1) Teste de Autenticação Sanctum Token

PHP
// tests/Feature/Api/ShopApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('requires authentication for API access', function () {
    $this->getJson('/api/v1/shops')
        ->assertUnauthorized();
});

it('authenticates with Sanctum token', function () {
    $user = User::factory()->create();
    $token = $user->createToken('test-token', ['read'])->plainTextToken;

    $this->withHeader('Authorization', "Bearer {$token}")
        ->getJson('/api/v1/shops')
        ->assertOk();
});

it('lists shops via API', function () {
    $user = User::factory()->create();
    $shops = Shop::factory()->count(3)->create(['tenant_id' => $user->tenant_id]);

    $response = $this->actingAs($user)
        ->getJson('/api/v1/shops');

    $response->assertOk()
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'name', 'slug', 'status', 'created_at'],
            ],
        ]);
});

it('creates shop via API', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/shops', [
            'name' => 'API Shop',
            'slug' => 'api-shop',
        ]);

    $response->assertCreated()
        ->assertJsonPath('data.name', 'API Shop');
});

it('rejects duplicate slug', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);
    Shop::factory()->create(['tenant_id' => $user->tenant_id, 'slug' => 'existing']);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/shops', [
            'name' => 'Duplicate',
            'slug' => 'existing',
        ]);

    $response->assertUnprocessable()
        ->assertJsonValidationErrors('slug');
});

(2) Métodos de JSON Assertion

Método Descrição
assertJson() JSON contém o fragmento especificado
assertJsonPath() Corresponder valores em um caminho especificado
assertJsonStructure() Correspondência de Estrutura JSON
assertJsonValidationErrors() Erro de validação: campo incluído
assertJsonCount() Correspondência de Comprimento de Array
assertJsonFragment() JSON contém um trecho

(1) ▶ Exemplo: Suite de Testes API do ShopMetrics

PHP
// tests/Feature/Api/OrderApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->owner = User::factory()->create(['role' => 'tenant_owner']);
    $this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});

it('lists orders with pagination', function () {
    Order::factory()->count(25)->create([
        'tenant_id' => $this->owner->tenant_id,
        'shop_id' => $this->shop->id,
    ]);

    $response = $this->actingAs($this->owner)
        ->getJson('/api/v1/orders?per_page=10');

    $response->assertOk()
        ->assertJsonCount(10, 'data')
        ->assertJsonStructure(['meta' => ['current_page', 'total']]);
});

it('shows order with items', function () {
    $order = Order::factory()->create([
        'tenant_id' => $this->owner->tenant_id,
        'shop_id' => $this->shop->id,
    ]);
    $order->items()->create(['product_id' => 1, 'quantity' => 2, 'price' => 10]);

    $response = $this->actingAs($this->owner)
        ->getJson("/api/v1/orders/{$order->id}");

    $response->assertOk()
        ->assertJsonPath('data.order_number', $order->order_number)
        ->assertJsonStructure(['data' => ['items']]);
});

it('updates order status', function () {
    $order = Order::factory()->create([
        'tenant_id' => $this->owner->tenant_id,
        'status' => 'pending',
    ]);

    Event::fake(OrderStatusChanged::class);

    $response = $this->actingAs($this->owner)
        ->patchJson("/api/v1/orders/{$order->id}/status", [
            'status' => 'completed',
        ]);

    $response->assertOk();
    expect($order->fresh()->status)->toBe('completed');
    Event::assertDispatched(OrderStatusChanged::class);
});

Saída:

TEXT
// Execução bem-sucedida

7. Cobertura de Testes e CI

(1) Relatório de Cobertura

BASH
# Gerar relatório de cobertura HTML
php artisan test --coverage-html=coverage

# Limiar mínimo de cobertura
php artisan test --coverage --min=80

# Cobertura para diretório específico
php artisan test --coverage-filter=app/Services

(2) GitHub Actions CI

YAML
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: shopmetrics_test
        ports:
          - 3306:3306
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
          coverage: xdebug
      - run: composer install --no-interaction
      - run: cp .env.testing .env
      - run: php artisan key:generate
      - run: php artisan test --coverage --min=80

(1) ▶ Exemplo: Configuração de Testes CI do ShopMetrics

YAML
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: [8.2, 8.3]
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
      - run: composer install
      - run: php artisan test --parallel
      - run: php artisan test --coverage --min=70
        if: matrix.php == '8.3'

  larastan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
      - run: composer install
      - run: ./vendor/bin/phpstan analyse --level=5

Saída:

TEXT
CONTAINER ID   IMAGE          STATUS         PORTS
abc123         nginx:latest   Up 2 hours     0.0.0.0:80->80/tcp

8. Exemplo Compreensivo: Suite de Testes do ShopMetrics

PHP
// ============================================
// Compreensivo: Suite de Testes do ShopMetrics
// Abrange: testes unitários, funcionais, API com Pest
// ============================================

// tests/Unit/Services/OrderServiceTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('calculates order total correctly', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 0, 'total' => 0]);
    $order->items()->createMany([
        ['product_id' => 1, 'quantity' => 2, 'price' => 15.00],
        ['product_id' => 2, 'quantity' => 3, 'price' => 10.00],
    ]);

    $service->calculateTotal($order);

    expect($order->fresh()->total)->toBe(60.00);
});

it('applies discount within limits', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 100, 'discount' => 0, 'total' => 100]);

    $service->applyDiscount($order, 20);

    expect($order->fresh()->total)->toBe(80.00);
});

it('rejects discount exceeding subtotal', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 100]);

    expect(fn () => $service->applyDiscount($order, 150))
        ->toThrow(InvalidArgumentException::class);
});

// tests/Feature/Api/CompleteOrderFlowTest.php
it('completes full order flow via API', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);
    $shop = Shop::factory()->create(['tenant_id' => $user->tenant_id]);
    $products = Product::factory()->count(3)->create(['shop_id' => $shop->id, 'stock' => 50]);

    Event::fake();
    Queue::fake();

    // Criar pedido
    $response = $this->actingAs($user)->postJson('/api/v1/orders', [
        'shop_id' => $shop->id,
        'items' => [
            ['product_id' => $products[0]->id, 'quantity' => 2],
            ['product_id' => $products[1]->id, 'quantity' => 1],
        ],
    ]);

    $response->assertCreated();
    $orderId = $response->json('data.id');

    // Verificar pedido no banco de dados
    $this->assertDatabaseHas('orders', ['id' => $orderId, 'status' => 'pending']);

    // Atualizar status para processamento
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'processing'])
        ->assertOk();

    // Atualizar status para concluído
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'completed'])
        ->assertOk();

    expect(Order::find($orderId)->status)->toBe('completed');
});

❓ Perguntas Frequentes

P Qual devo escolher, PHPUnit ou Pest?
R Recomendamos Pest para novos projetos — tem uma sintaxe mais concisa e é mais legível. Pest é construído sobre PHPUnit, então suporta todas as funcionalidades do PHPUnit. Você pode usar ambos juntos em projetos PHPUnit existentes.
P Qual é a diferença entre RefreshDatabase e DatabaseMigrations?
R RefreshDatabase executa migrate primeiro e depois envolve cada teste em uma transação (para rollback mais rápido); DatabaseMigrations executa migrate antes de cada teste e rollback após cada teste. RefreshDatabase é mais rápido e é recomendado.
P Como faço mock de serviços externos durante testes?
R Use Http::fake() para simular respostas HTTP: Http::fake(['api.stripe.com/*' => Http::response(['status' => 'ok'])]). Use Mail::fake() para email, Event::fake() para eventos e Queue::fake() para filas.
P Qual é um nível adequado de cobertura de testes?
R 80%+ para lógica de negócios principal, 70%+ para métodos de model e 60%+ para controllers. Visar 100% de cobertura não é custo-efetivo — concentre-se em cobrir caminhos de negócios principais e lógica complexa, em vez de cada getter e setter.
P Como posso executar testes em paralelo no CI?
R php artisan test --parallel=4 Execute-os em paralelo usando 4 processos. Cada teste deve ser independente (não dependente de dados de outros testes) e use um banco de dados SQLite em memória para evitar contenção de banco de dados.
P Como lidar com uploads de arquivos em testes?
R Use Storage::fake('disk') para simular o sistema de arquivos e UploadedFile::fake()->create('test.jpg') para criar um arquivo fictício. O arquivo fictício é limpo automaticamente após o teste.

📖 Resumo


📝 Exercícios

  1. Exercício Básico (⭐): Escreva três testes unitários para o model Shop (active scope, revenue_formatted accessor e geração de slug) usando sintaxe Pest e garanta que todos passem.

  2. Exercício Avançado (⭐⭐): Escreva testes funcionais para as operações CRUD do ShopController (index/show/store/update/destroy), incluindo assertions para falhas de validação e verificações de permissão, usando a trait RefreshDatabase.

  3. Desafio (⭐⭐⭐): Escreva um teste completo de fluxo de pedido via API — criar pedido → atualizar status → verificar mudanças no banco de dados → mock de eventos e filas → confirmar que Event::assertDispatched e Queue::assertPushed passam, com taxa de cobertura ≥ 80%.

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%