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();