Communitygithub.com

Laravel TDD

Estratégias de teste para Laravel com PHPUnit, Pest, model factories, testes HTTP, autenticação Sanctum, mocks e cobertura. Use ao escrever testes Laravel com PHPUnit ou Pest, ou ao conduzir um teste de funcionalidade Laravel primeiro.

O que é Laravel TDD?

Esta skill oferece um guia completo sobre desenvolvimento orientado a testes (TDD) para aplicações Laravel, utilizando ferramentas como PHPUnit, Pest, Laravel factories e helpers de teste. Ela é útil para qualquer desenvolvedor que deseja garantir a qualidade e robustez de suas aplicações Laravel, seguindo o ciclo Red-Green-Refactor. A skill cobre a configuração inicial do ambiente de testes, criação de factories, testes de modelos, testes de API HTTP, autenticação com Sanctum, mocks de serviços externos (HTTP, Mail, Queue, Storage, Events) e testes de comandos Artisan. O conteúdo é apresentado com exemplos práticos de código, demonstrando as melhores práticas para cada cenário, e pode ser aplicado em projetos que utilizam PHPUnit ou Pest como framework de teste.

Funciona com~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/everything-claude-code/tree/main/skills/laravel-tdd

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Testando Laravel com TDD

Desenvolvimento orientado a testes para aplicações Laravel usando PHPUnit, Pest, Laravel factories e helpers de teste.

Quando ativar

  • Escrevendo novas aplicações ou funcionalidades Laravel
  • Implementando endpoints de API com autenticação Sanctum ou Passport
  • Testando modelos Eloquent, relacionamentos, escopos e accessors
  • Configurando a infraestrutura de testes para projetos Laravel
  • Escrevendo testes de funcionalidade para controladores HTTP e form requests
  • Mockando serviços externos (filas, e-mail, notificações, HTTP)

Fluxo de trabalho TDD para Laravel

Ciclo Vermelho-Verde-Refatoração

// Passo 1: VERMELHO — Escreva um teste que falha
public function test_a_product_can_be_created(): void
{
    $product = Product::factory()->create(['name' => 'Produto de Teste']);
    $this->assertDatabaseHas('products', ['name' => 'Produto de Teste']);
}

// Passo 2: VERDE — Escreva a migration, o modelo e a factory
// Passo 3: REFATORAÇÃO — Melhore enquanto mantém os testes verdes

Configuração

Configuração do PHPUnit

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
    </php>
</phpunit>

Configuração da Classe Base TestCase

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        // Use $this->withoutExceptionHandling() apenas em testes que
        // testam exceções não HTTP; ele suprime assertStatus() etc.
    }

    // Helper: Autenticar e retornar usuário
    protected function actingAsUser(): mixed
    {
        $user = \App\Models\User::factory()->create();
        $this->actingAs($user);
        return $user;
    }

    protected function actingAsAdmin(): mixed
    {
        $admin = \App\Models\User::factory()->admin()->create();
        $this->actingAs($admin);
        return $admin;
    }
}

Model Factories

// database/factories/UserFactory.php
class UserFactory extends Factory
{
    protected static ?string $password = null;

    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'email_verified_at' => now(),
            'password' => static::$password ??= Hash::make('password'),
            'remember_token' => Str::random(10),
            'role' => 'user',
        ];
    }

    public function admin(): static
    {
        return $this->state(fn (array $attributes) => ['role' => 'admin']);
    }

    public function unverified(): static
    {
        return $this->state(fn (array $attributes) => ['email_verified_at' => null]);
    }
}

// database/factories/ProductFactory.php
class ProductFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name' => fake()->unique()->words(3, true),
            'slug' => fn (array $attrs) => Str::slug($attrs['name']),
            'description' => fake()->paragraph(),
            'price' => fake()->numberBetween(100, 100000),
            'stock' => fake()->numberBetween(0, 100),
            'is_active' => true,
            'user_id' => UserFactory::new(),
        ];
    }

    public function outOfStock(): static
    {
        return $this->state(fn (array $attributes) => ['stock' => 0]);
    }
}

Usando Factories

$user = User::factory()->create();
$admin = User::factory()->admin()->create();
$product = Product::factory()->create(['user_id' => $user->id]);
$products = Product::factory()->count(10)->create();
$draft = Product::factory()->make(); // Não persistido

// Com relacionamentos
$user = User::factory()->has(Product::factory()->count(3))->create();

// Sequências
User::factory()->count(3)->sequence(
    ['role' => 'admin'], ['role' => 'editor'], ['role' => 'user'],
)->create();

Testes de Modelos

namespace Tests\Unit\Models;

use App\Models\User;
use App\Models\Product;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_hides_sensitive_attributes(): void
    {
        $user = User::factory()->create();
        $this->assertArrayNotHasKey('password', $user->toArray());
    }

    public function test_admin_scope_returns_only_admins(): void
    {
        User::factory()->admin()->create();
        User::factory()->count(3)->create();

        $this->assertCount(1, User::admin()->get());
    }
}

class ProductTest extends TestCase
{
    use RefreshDatabase;

    public function test_active_scope_filters_correctly(): void
    {
        Product::factory()->count(3)->create(['is_active' => true]);
        Product::factory()->count(2)->create(['is_active' => false]);

        $this->assertCount(3, Product::active()->get());
    }

    public function test_it_belongs_to_a_user(): void
    {
        $user = User::factory()->create();
        $product = Product::factory()->create(['user_id' => $user->id]);

        $this->assertTrue($product->user->is($user));
    }
}

Testes de Funcionalidade / HTTP

namespace Tests\Feature\Http\Controllers;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_guests_are_redirected_to_login(): void
    {
        $this->get(route('products.create'))->assertRedirect(route('login'));
    }

    public function test_it_stores_a_new_product(): void
    {
        $user = User::factory()->create();
        $this->actingAs($user);

        $response = $this->post(route('products.store'), [
            'name' => 'Novo Produto',
            'description' => 'Descrição',
            'price' => 2999,
            'stock' => 10,
        ]);

        $response->assertRedirect(route('products.index'));
        $this->assertDatabaseHas('products', [
            'name' => 'Novo Produto',
            'user_id' => $user->id,
        ]);
    }

    public function test_it_validates_required_fields(): void
    {
        $this->actingAs(User::factory()->create());
        $this->post(route('products.store'), [])
            ->assertSessionHasErrors(['name', 'price']);
    }

    public function test_users_cannot_modify_others_products(): void
    {
        $owner = User::factory()->create();
        $attacker = User::factory()->create();
        $product = Product::factory()->create(['user_id' => $owner->id]);

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

Testes de API JSON

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_unauthenticated_requests_are_rejected(): void
    {
        $this->getJson('/api/products')->assertUnauthorized();
    }

    public function test_it_lists_paginated_products(): void
    {
        $user = User::factory()->create();
        Product::factory()->count(5)->create(['user_id' => $user->id]);

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

        $response->assertOk();
        $response->assertJsonCount(5, 'data');
        $response->assertJsonStructure([
            'data' => [['id', 'name', 'price']],
            'meta' => ['current_page', 'last_page', 'total'],
        ]);
    }

    public function test_it_creates_a_product(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/products', [
            'name' => 'Produto API',
            'price' => 4999,
        ]);

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

    public function test_users_cannot_delete_others_products(): void
    {
        $owner = User::factory()->create();
        $attacker = User::factory()->create();
        $product = Product::factory()->create(['user_id' => $owner->id]);

        $this->actingAs($attacker)
            ->deleteJson("/api/products/{$product->id}")
            ->assertForbidden();
    }
}

Testes de Autenticação com Sanctum API

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;

class AuthControllerTest extends TestCase
{
    use RefreshDatabase;

    public function test_users_can_register(): void
    {
        $response = $this->postJson('/api/register', [
            'name' => 'Usuário de Teste',
            'email' => '[email protected]',
            'password' => 'Senha123!',
            'password_confirmation' => 'Senha123!',
        ]);

        $response->assertCreated();
        $response->assertJsonStructure(['data' => ['user', 'token']]);
    }

    public function test_users_can_login(): void
    {
        User::factory()->create([
            'email' => '[email protected]',
            'password' => Hash::make('Senha123!'),
        ]);

        $response = $this->postJson('/api/login', [
            'email' => '[email protected]',
            'password' => 'Senha123!',
        ]);

        $response->assertOk();
        $response->assertJsonStructure(['data' => ['token']]);
    }

    public function test_users_cannot_login_with_wrong_password(): void
    {
        User::factory()->create(['email' => '[email protected]']);

        $this->postJson('/api/login', [
            'email' => '[email protected]',
            'password' => 'errada',
        ])->assertUnprocessable();
    }

    public function test_token_bearer_authenticates_requests(): void
    {
        $user = User::factory()->create();
        $token = $user->createToken('teste')->plainTextToken;

        $this->withToken($token)
            ->getJson('/api/user')
            ->assertOk()
            ->assertJsonPath('data.email', $user->email);
    }
}

Mocks e Fakes

Fake HTTP

use Illuminate\Support\Facades\Http;

public function test_it_handles_successful_payment(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200),
    ]);

    $result = (new PaymentService())->charge(2999);
    $this->assertTrue($result->success);
}

public function test_it_handles_gateway_failure(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::response(['error' => 'card_declined'], 402),
    ]);

    $this->expectException(PaymentFailedException::class);
    (new PaymentService())->charge(2999);
}

public function test_it_retries_on_timeout(): void
{
    Http::fake([
        'api.stripe.com/*' => Http::sequence()
            ->pushStatus(408)
            ->pushStatus(200),
    ]);

    $this->assertTrue((new PaymentService())->charge(2999)->success);
}

Fake de E-mail

Mail::fake();

$order->sendConfirmation();

Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {
    return $mail->hasTo($order->user->email);
});

Fake de Notificações

Notification::fake();

$user->notify(new WelcomeUser());

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

Fake de Filas

Queue::fake();

ProcessImage::dispatch($product);

Queue::assertPushed(ProcessImage::class, function ($job) use ($product) {
    return $job->product->id === $product->id;
});

Fake de Armazenamento

Storage::fake('public');

$file = UploadedFile::fake()->image('foto.jpg', 200, 200);

$response = $this->actingAs($user)->post('/avatar', [
    'avatar' => $file,
]);

$response->assertSessionHasNoErrors();
Storage::disk('public')->assertExists('avatars/' . $file->hashName());

Fake de Eventos

Event::fake();

$order->markAsShipped();

Event::assertDispatched(OrderShipped::class, function ($event) use ($order) {
    return $event->order->id === $order->id;
});

Testes de Comandos Artisan

public function test_it_sends_newsletters(): void
{
    Mail::fake();
    User::factory()->count(5)->create(['subscribed' => true]);

    $this->artisan('newsletter:send')
        ->expectsOutput('Enviando newsletter para 5 assinantes...')
        ->assertExitCode(0);

    Mail::assertSent(NewsletterMail::class, 5);
}

public function test_it_handles_no_subscribers(): void
{
    $this->artisan('newsletter:send')
        ->expectsOutput('Nenhum assinante encontrado.')
        ->assertExitCode(0);
}

Testes de Autorização

public function test_users_can_update_own_posts(): void
{
    $user = User::factory()->create();
    $post = Post::factory()->create(['user_id' => $user->id]);

    $this->actingAs($user)
        ->put(route('posts.update', $post), ['title' => 'Atualizado'])
        ->assertRedirect();
}

public function test_users_cannot_update_others_posts(): void
{
    $post = Post::factory()->create();
    $this->actingAs(User::factory()->create())
        ->put(route('posts.update', $post), ['title' => 'Hackeado'])
        ->assertForbidden();
}

public function test_it_paginates_posts(): void
{
    $user = User::factory()->create();
    Post::factory()->count(5)->create(['user_id' => $user->id]);

    $response = $this->actingAs($user)
        ->get(route('posts.index') . '?per_page=2');

    $response->assertOk();
    $this->assertCount(2, $response['data']);
    $this->assertEquals(3, $response['meta']['last_page']);
}

Testes de Validação de Form Request

namespace Tests\Feature\Http\Controllers;

use App\Models\User;
use App\Models\Category;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductControllerValidationTest extends TestCase
{
    use RefreshDatabase;

    public function test_requires_valid_data_for_store(): void
    {
        $this->actingAs(User::factory()->create());

        $this->post(route('products.store'), [
            'name' => '',
            'price' => -10,
            'category_id' => 999,
        ])
        ->assertSessionHasErrors(['name', 'price', 'category_id']);
    }

    public function test_requires_unique_name(): void
    {
        $this->actingAs(User::factory()->create());
        Product::factory()->create(['name' => 'Produto Existente']);

        $this->post(route('products.store'), [
            'name' => 'Produto Existente',
            'price' => 1000,
        ])
        ->assertSessionHasErrors('name');
    }
}

Cobertura de Código

Gerando Relatório

phpunit --coverage-html coverage

Configuração para Ignorar Cobertura

Adicione anotações @codeCoverageIgnore em métodos que não precisam de cobertura (ex.: getters/setters triviais, helpers de depuração).

/** @codeCoverageIgnore */
public function getDebugInfo(): array
{
    return $this->toArray();
}

Testes Assíncronos e Concorrência

Testando Jobs em Lote com Bus::batch

public function test_it_dispatches_batch_process(): void
{
    Bus::fake();

    $products = Product::factory()->count(3)->create();

    Bus::batch([
        new ProcessProduct($products[0]),
        new ProcessProduct($products[1]),
    ])->dispatch();

    Bus::assertBatched(function (Batch $batch) {
        return $batch->jobs->count() === 2;
    });
}

Testando Rate Limiting

public function test_it_limits_requests(): void
{
    $user = User::factory()->create();

    foreach (range(1, 60) as $i) {
        $this->actingAs($user)->getJson('/api/products')->assertOk();
    }

    $this->actingAs($user)->getJson('/api/products')->assertTooManyRequests();
}

Testes de Banco de Dados

RefreshDatabase vs. DatabaseTransactions

  • RefreshDatabase: Migra novamente o banco de dados a cada teste (mais lento, mas garante nenhum dado residual).
  • DatabaseTransactions: Envolve cada teste em uma transação revertida (mais rápido, mas é executado dentro da mesma migração).
// Para a maioria dos testes, use RefreshDatabase
use RefreshDatabase;

// Para testes que não modificam a estrutura do banco, DatabaseTransactions é mais rápido
use DatabaseTransactions;

Diretrizes Gerais de Teste

  1. Escreva testes primeiro (TDD): sempre siga o ciclo Vermelho-Verde-Refatoração.
  2. Mantenha os testes focados: cada método de teste deve verificar um comportamento específico.
  3. Use nomes descritivos: test_it_rejects_duplicate_emails() é melhor que test_create_validation().
  4. Evite dependências entre testes: os testes devem rodar de forma independente.
  5. Use RefreshDatabase para testes de funcionalidade: garante um estado limpo.
  6. Prefira fakes em vez de mocks: a menos que você precise verificar interações específicas.
  7. Cubra casos de borda: não apenas o caminho feliz, mas também erros e permissões.
  8. Mire em alta cobertura: mas não em 100%, foque em lógica crítica de negócios.

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/claude-api

Anthropic Claude API patterns for Python and TypeScript. Covers Messages API, streaming, tool use, vision, extended thinking, batches, prompt caching, and Claude Agent SDK. Use when building applications with the Claude API or Anthropic SDKs.

affaan-m/everything-claude-code

End-to-end marketing campaign planning and execution. Covers audience research, positioning, campaign angle definition, landing page copy, email sequences, social posts, ad copy, short-form video scripts, and content calendars. Use as the orchestration layer for multi-channel product launches. Use when planning or executing a multi-channel product launch, or producing landing page, email, social, or ad copy.

affaan-m/everything-claude-code

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/everything-claude-code-conventions

Development conventions and patterns for everything-claude-code. JavaScript project with conventional commits.

affaan-m/frontend-design

Create distinctive, production-grade frontend interfaces with high design quality. Use when the user asks to build web components, pages, or applications and the visual direction matters as much as the code quality.

affaan-m/gget

gget CLI and Python workflow for quick genomic database queries, sequence lookup, BLAST-style searches, enrichment checks, and reproducible bioinformatics evidence logs.

affaan-m/literature-review

Systematic literature-review workflow for academic, biomedical, technical, and scientific topics, including search planning, source screening, synthesis, citation checks, and evidence logging.

affaan-m/motion-ui

Production-ready UI motion system for React/Next.js. Use when implementing animations, transitions, or motion patterns.

affaan-m/project-guidelines-example

Example project-specific skill template based on a real production application.

affaan-m/pubmed-database

Direct PubMed and NCBI E-utilities search workflows for biomedical literature, MeSH queries, PMID lookup, citation retrieval, and API-backed literature monitoring.

affaan-m/scholar-evaluation

Structured scholarly-work evaluation for papers, proposals, literature reviews, methods sections, evidence quality, citation support, and research-writing feedback.

affaan-m/uspto-database

USPTO patent and trademark data workflow for official record lookup, PatentSearch queries, TSDR checks, assignment data, and reproducible IP research logs.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

Habilidades Relacionadas