Testing

Testing Standards

Framework

All projects use Pest PHP for testing.

Directory Structure

tests/
├── Feature/
│   ├── Actions/
│   │   ├── Tickets/
│   │   │   └── CreateTicketTest.php
│   │   └── Users/
│   │       └── BuildUserSettingDefaultsTest.php
│   ├── Models/
│   │   ├── UserTest.php
│   │   └── TicketTest.php
│   └── Auth/
│       ├── LoginTest.php
│       └── RegistrationTest.php
├── Unit/
│   ├── ArchitectureTest.php
│   └── ExampleTest.php
├── Browser/                 # Dusk tests (optional)
│   └── SmokeTest.php
├── Pest.php                 # Pest configuration
└── TestCase.php             # Base test case

Pest Configuration

// tests/Pest.php
<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

pest()->extends(TestCase::class)
    ->use(RefreshDatabase::class)
    ->in('Feature');

pest()->extends(TestCase::class)
    ->in('Unit');

Basic Test Structure

<?php

declare(strict_types=1);

use App\Models\User;
use App\Models\Ticket;

test('can view dashboard', function (): void {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->get('/dashboard')
        ->assertOk()
        ->assertSee('Dashboard');
});

test('guests cannot view dashboard', function (): void {
    $this->get('/dashboard')
        ->assertRedirect('/login');
});

Expectations (Assertions)

Basic Expectations

test('can create a ticket', function (): void {
    $ticket = Ticket::factory()->create();

    expect($ticket)
        ->toBeInstanceOf(Ticket::class)
        ->id->toBeInt()
        ->title->toBeString()
        ->is_active->toBeTrue();
});

Chained Expectations

test('ticket has correct attributes', function (): void {
    $ticket = Ticket::factory()->create([
        'title' => 'Test Ticket',
        'is_active' => true,
    ]);

    expect($ticket)
        ->title->toBe('Test Ticket')
        ->is_active->toBeTrue()
        ->user_id->toBeInt()
        ->and($ticket->user)
        ->toBeInstanceOf(User::class);
});

Collection Expectations

test('can retrieve active tickets', function (): void {
    Ticket::factory()->count(3)->create(['is_active' => true]);
    Ticket::factory()->count(2)->create(['is_active' => false]);

    $activeTickets = Ticket::active()->get();

    expect($activeTickets)
        ->toHaveCount(3)
        ->each->is_active->toBeTrue();
});

Testing Actions

<?php

declare(strict_types=1);

use App\Actions\Tickets\CreateTicket;
use App\Data\TicketData;
use App\Models\Category;
use App\Models\Priority;
use App\Models\Ticket;
use App\Models\User;

test('can create a ticket with all fields', function (): void {
    $user = User::factory()->create();
    $category = Category::factory()->create();
    $priority = Priority::factory()->create();

    $data = new TicketData(
        title: 'Test Ticket',
        body: 'This is a test ticket body.',
        user_id: $user->id,
        category_id: $category->id,
        priority_id: $priority->id,
    );

    $ticket = app(CreateTicket::class)->execute($data);

    expect($ticket)
        ->toBeInstanceOf(Ticket::class)
        ->title->toBe('Test Ticket')
        ->body->toBe('This is a test ticket body.')
        ->user_id->toBe($user->id)
        ->category_id->toBe($category->id);
});

test('can create a ticket with minimal fields', function (): void {
    $user = User::factory()->create();

    $data = new TicketData(
        title: 'Minimal Ticket',
        body: 'Just the basics.',
        user_id: $user->id,
    );

    $ticket = app(CreateTicket::class)->execute($data);

    expect($ticket)
        ->title->toBe('Minimal Ticket')
        ->category_id->toBeNull();
});

Testing Models

<?php

declare(strict_types=1);

use App\Models\Ticket;
use App\Models\User;

test('ticket belongs to user', function (): void {
    $ticket = Ticket::factory()->create();

    expect($ticket->user)
        ->toBeInstanceOf(User::class);
});

test('user has many tickets', function (): void {
    $user = User::factory()
        ->has(Ticket::factory()->count(3))
        ->create();

    expect($user->tickets)
        ->toHaveCount(3)
        ->each->toBeInstanceOf(Ticket::class);
});

test('ticket scope filters active', function (): void {
    Ticket::factory()->count(2)->create(['is_active' => true]);
    Ticket::factory()->count(3)->create(['is_active' => false]);

    expect(Ticket::active()->count())->toBe(2);
});

test('ticket scope filters due this week', function (): void {
    Ticket::factory()->create(['due_at' => now()]);
    Ticket::factory()->create(['due_at' => now()->addWeeks(2)]);

    expect(Ticket::dueThisWeek()->count())->toBe(1);
});

Testing HTTP

<?php

declare(strict_types=1);

use App\Models\User;
use App\Models\Ticket;

test('can view ticket list', function (): void {
    $user = User::factory()->create();
    Ticket::factory()->count(3)->create(['user_id' => $user->id]);

    $this->actingAs($user)
        ->get('/tickets')
        ->assertOk()
        ->assertViewHas('tickets');
});

test('can create ticket via form', function (): void {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post('/tickets', [
            'title' => 'New Ticket',
            'body' => 'Ticket description',
        ])
        ->assertRedirect('/tickets');

    $this->assertDatabaseHas('tickets', [
        'title' => 'New Ticket',
        'user_id' => $user->id,
    ]);
});

test('validates required fields', function (): void {
    $user = User::factory()->create();

    $this->actingAs($user)
        ->post('/tickets', [])
        ->assertSessionHasErrors(['title', 'body']);
});

Testing Livewire

<?php

declare(strict_types=1);

use App\Livewire\TicketList;
use App\Livewire\TicketCreate;
use App\Models\User;
use App\Models\Ticket;
use Livewire\Livewire;

test('can render ticket list', function (): void {
    $user = User::factory()->create();

    Livewire::actingAs($user)
        ->test(TicketList::class)
        ->assertOk();
});

test('can create ticket', function (): void {
    $user = User::factory()->create();

    Livewire::actingAs($user)
        ->test(TicketCreate::class)
        ->set('title', 'New Ticket')
        ->set('body', 'Ticket body')
        ->call('save')
        ->assertHasNoErrors();

    $this->assertDatabaseHas('tickets', [
        'title' => 'New Ticket',
        'user_id' => $user->id,
    ]);
});

test('validates required fields', function (): void {
    $user = User::factory()->create();

    Livewire::actingAs($user)
        ->test(TicketCreate::class)
        ->set('title', '')
        ->call('save')
        ->assertHasErrors(['title' => 'required']);
});

test('can search tickets', function (): void {
    $user = User::factory()->create();
    Ticket::factory()->create(['title' => 'Bug Report', 'user_id' => $user->id]);
    Ticket::factory()->create(['title' => 'Feature Request', 'user_id' => $user->id]);

    Livewire::actingAs($user)
        ->test(TicketList::class)
        ->set('search', 'Bug')
        ->assertSee('Bug Report')
        ->assertDontSee('Feature Request');
});

Architecture Tests

Architecture tests enforce coding standards automatically. Run them with:

php artisan test tests/Unit/ArchitectureTest.php
# or
./vendor/bin/pest tests/Unit/ArchitectureTest.php
// tests/Unit/ArchitectureTest.php
<?php

declare(strict_types=1);

arch()
    ->expect('App')
    ->not->toUse(['die', 'dd', 'dump', 'ray', 'var_dump', 'print_r']);

arch()
    ->expect('App\Models')
    ->toExtend('Illuminate\Database\Eloquent\Model')
    ->toHaveMethod('casts');

arch()
    ->expect('App\Actions')
    ->toHaveMethod('execute')
    ->toBeFinal();

arch()
    ->expect('App\Data')
    ->toExtend('Spatie\LaravelData\Data')
    ->toBeFinal();

arch()
    ->expect('App\Livewire')
    ->toExtend('Livewire\Component')
    ->toBeFinal();

arch()->preset()->php();
arch()->preset()->security()->ignoring('md5');
arch()->preset()->laravel()
    ->ignoring('storage')
    ->ignoring('bootstrap/cache');

Event Testing

<?php

declare(strict_types=1);

use App\Events\TicketCreated;
use App\Models\Ticket;
use Illuminate\Support\Facades\Event;

test('dispatches event when ticket created', function (): void {
    Event::fake();

    $ticket = Ticket::factory()->create();

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

test('does not dispatch event when ticket updated', function (): void {
    Event::fake();

    $ticket = Ticket::factory()->create();
    $ticket->update(['title' => 'Updated']);

    Event::assertNotDispatched(TicketCreated::class);
});

Database Testing

<?php

declare(strict_types=1);

use App\Models\Ticket;
use App\Models\User;

test('creates ticket in database', function (): void {
    $user = User::factory()->create();

    Ticket::factory()->create([
        'title' => 'Database Test',
        'user_id' => $user->id,
    ]);

    $this->assertDatabaseHas('tickets', [
        'title' => 'Database Test',
        'user_id' => $user->id,
    ]);
});

test('soft deletes ticket', function (): void {
    $ticket = Ticket::factory()->create();

    $ticket->delete();

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

test('database count is correct', function (): void {
    Ticket::factory()->count(5)->create();

    $this->assertDatabaseCount('tickets', 5);
});

Time Travel

<?php

declare(strict_types=1);

use App\Models\Ticket;

test('ticket is overdue when past due date', function (): void {
    $ticket = Ticket::factory()->create([
        'due_at' => now()->addDay(),
    ]);

    expect($ticket->isOverdue())->toBeFalse();

    $this->travel(2)->days();

    expect($ticket->fresh()->isOverdue())->toBeTrue();
});

test('reminders due this week', function (): void {
    $this->travelTo(now()->startOfWeek());

    Ticket::factory()->create(['due_at' => now()->addDays(3)]);
    Ticket::factory()->create(['due_at' => now()->addWeeks(2)]);

    expect(Ticket::dueThisWeek()->count())->toBe(1);
});

Factory Best Practices

// database/factories/TicketFactory.php
<?php

declare(strict_types=1);

namespace Database\Factories;

use App\Models\Category;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

final class TicketFactory extends Factory
{
    protected $model = Ticket::class;

    public function definition(): array
    {
        return [
            'title' => fake()->sentence(),
            'body' => fake()->paragraphs(3, true),
            'is_active' => true,
            'user_id' => User::factory(),
            'category_id' => null,
        ];
    }

    public function inactive(): static
    {
        return $this->state(['is_active' => false]);
    }

    public function withCategory(): static
    {
        return $this->state(['category_id' => Category::factory()]);
    }

    public function dueToday(): static
    {
        return $this->state(['due_at' => now()]);
    }
}

// Usage in tests
Ticket::factory()->inactive()->create();
Ticket::factory()->withCategory()->dueToday()->create();