Testing Guide

Comprehensive guide to writing tests for LaraDashboard applications including unit tests, feature tests, Livewire tests, and API tests.

Testing Guide

LaraDashboard uses Pest PHP for testing, providing an elegant and expressive syntax for writing tests. This guide covers testing practices for all aspects of the application.

Testing Setup

Prerequisites

Tests are pre-configured with:

  • Pest PHP - Modern testing framework
  • RefreshDatabase - Fresh database for each test
  • Factories - Model factories for test data
  • Sanctum - API authentication testing

Running Tests

# Run all tests
php artisan test

# Run with coverage
php artisan test --coverage

# Run specific file
php artisan test tests/Feature/UserTest.php

# Run specific test
php artisan test --filter="can_create_user"

# Run tests in parallel
php artisan test --parallel

Test Structure

tests/
├── Feature/                 # Integration tests
│   ├── Auth/
│   │   ├── LoginTest.php
│   │   └── RegistrationTest.php
│   ├── User/
│   │   ├── UserCrudTest.php
│   │   └── UserPermissionTest.php
│   ├── Post/
│   │   └── PostCrudTest.php
│   ├── Api/
│   │   ├── UserApiTest.php
│   │   └── PostApiTest.php
│   └── Livewire/
│       └── DatatableTest.php
│
├── Unit/                    # Unit tests
│   ├── Services/
│   │   ├── UserServiceTest.php
│   │   └── PostServiceTest.php
│   └── Models/
│       ├── UserTest.php
│       └── PostTest.php
│
├── Pest.php                 # Pest configuration
└── TestCase.php             # Base test class

Writing Tests

Basic Pest Syntax

<?php

use App\Models\User;

test('user can be created', function () {
    $user = User::factory()->create([
        'email' => 'test@example.com',
    ]);

    expect($user->email)->toBe('test@example.com');
    expect($user)->toBeInstanceOf(User::class);
});

it('generates unique slugs', function () {
    $user1 = User::factory()->create(['name' => 'John Doe']);
    $user2 = User::factory()->create(['name' => 'John Doe']);

    expect($user1->slug)->not->toBe($user2->slug);
});

Grouping Tests

<?php

describe('User creation', function () {
    it('requires an email', function () {
        // Test
    });

    it('requires a password', function () {
        // Test
    });

    it('sends welcome email', function () {
        // Test
    });
});

Setup and Teardown

<?php

beforeEach(function () {
    $this->admin = User::factory()->create();
    $this->admin->assignRole('admin');
});

afterEach(function () {
    // Cleanup
});

test('admin can access dashboard', function () {
    $this->actingAs($this->admin)
        ->get('/admin')
        ->assertOk();
});

Feature Tests

Authentication Tests

<?php

use App\Models\User;

describe('Authentication', function () {
    it('allows users to login with valid credentials', function () {
        $user = User::factory()->create([
            'password' => bcrypt('password'),
        ]);

        $response = $this->post('/login', [
            'email' => $user->email,
            'password' => 'password',
        ]);

        $response->assertRedirect('/admin');
        $this->assertAuthenticatedAs($user);
    });

    it('prevents login with invalid credentials', function () {
        $user = User::factory()->create();

        $response = $this->post('/login', [
            'email' => $user->email,
            'password' => 'wrong-password',
        ]);

        $response->assertSessionHasErrors('email');
        $this->assertGuest();
    });

    it('allows users to logout', function () {
        $user = User::factory()->create();

        $this->actingAs($user)
            ->post('/logout')
            ->assertRedirect('/');

        $this->assertGuest();
    });
});

CRUD Tests

<?php

use App\Models\User;
use App\Models\Post;

describe('Post CRUD', function () {
    beforeEach(function () {
        $this->user = User::factory()->create();
        $this->user->givePermissionTo([
            'posts.view',
            'posts.create',
            'posts.edit',
            'posts.delete',
        ]);
    });

    it('displays post list', function () {
        $posts = Post::factory()->count(3)->create();

        $this->actingAs($this->user)
            ->get('/admin/posts')
            ->assertOk()
            ->assertSee($posts[0]->title)
            ->assertSee($posts[1]->title);
    });

    it('creates a new post', function () {
        $postData = [
            'title' => 'Test Post',
            'content' => 'Test content',
            'status' => 'draft',
        ];

        $this->actingAs($this->user)
            ->post('/admin/posts', $postData)
            ->assertRedirect();

        $this->assertDatabaseHas('posts', [
            'title' => 'Test Post',
        ]);
    });

    it('updates an existing post', function () {
        $post = Post::factory()->create();

        $this->actingAs($this->user)
            ->put("/admin/posts/{$post->id}", [
                'title' => 'Updated Title',
                'content' => 'Updated content',
            ])
            ->assertRedirect();

        expect($post->fresh()->title)->toBe('Updated Title');
    });

    it('deletes a post', function () {
        $post = Post::factory()->create();

        $this->actingAs($this->user)
            ->delete("/admin/posts/{$post->id}")
            ->assertRedirect();

        $this->assertSoftDeleted('posts', ['id' => $post->id]);
    });
});

Permission Tests

<?php

use App\Models\User;
use App\Models\Post;

describe('Post Permissions', function () {
    it('prevents unauthorized users from creating posts', function () {
        $user = User::factory()->create();
        // User has no permissions

        $this->actingAs($user)
            ->post('/admin/posts', [
                'title' => 'Test',
                'content' => 'Content',
            ])
            ->assertForbidden();
    });

    it('allows only own post editing for authors', function () {
        $author = User::factory()->create();
        $author->givePermissionTo('posts.edit_own');

        $ownPost = Post::factory()->create(['user_id' => $author->id]);
        $otherPost = Post::factory()->create();

        $this->actingAs($author)
            ->get("/admin/posts/{$ownPost->id}/edit")
            ->assertOk();

        $this->actingAs($author)
            ->get("/admin/posts/{$otherPost->id}/edit")
            ->assertForbidden();
    });
});

Livewire Tests

Component Testing

<?php

use App\Livewire\Datatable\UserDatatable;
use App\Models\User;
use Livewire\Livewire;

describe('User Datatable', function () {
    beforeEach(function () {
        $this->admin = User::factory()->create();
        $this->admin->assignRole('admin');
    });

    it('renders the component', function () {
        Livewire::actingAs($this->admin)
            ->test(UserDatatable::class)
            ->assertStatus(200)
            ->assertSee('Users');
    });

    it('searches users by name', function () {
        User::factory()->create(['first_name' => 'John']);
        User::factory()->create(['first_name' => 'Jane']);

        Livewire::actingAs($this->admin)
            ->test(UserDatatable::class)
            ->set('search', 'John')
            ->assertSee('John')
            ->assertDontSee('Jane');
    });

    it('filters by role', function () {
        $admin = User::factory()->create();
        $admin->assignRole('admin');

        $subscriber = User::factory()->create();
        $subscriber->assignRole('subscriber');

        Livewire::actingAs($this->admin)
            ->test(UserDatatable::class)
            ->set('roleFilter', 'admin')
            ->assertSee($admin->name)
            ->assertDontSee($subscriber->name);
    });

    it('sorts users by column', function () {
        User::factory()->create(['first_name' => 'Alice']);
        User::factory()->create(['first_name' => 'Zach']);

        $component = Livewire::actingAs($this->admin)
            ->test(UserDatatable::class)
            ->call('sortBy', 'first_name');

        // Assert first user is Alice (A comes before Z)
        expect($component->viewData('users')->first()->first_name)->toBe('Alice');
    });

    it('deletes a user', function () {
        $userToDelete = User::factory()->create();

        Livewire::actingAs($this->admin)
            ->test(UserDatatable::class)
            ->call('deleteUser', $userToDelete->id)
            ->assertDispatched('notify');

        $this->assertSoftDeleted('users', ['id' => $userToDelete->id]);
    });

    it('paginates results', function () {
        User::factory()->count(25)->create();

        Livewire::actingAs($this->admin)
            ->test(UserDatatable::class)
            ->assertSee('Showing')
            ->set('perPage', 10)
            ->assertViewHas('users', fn($users) => $users->count() === 10);
    });
});

Form Component Tests

<?php

use App\Livewire\Admin\CreatePost;
use App\Models\User;
use Livewire\Livewire;

describe('Create Post Component', function () {
    beforeEach(function () {
        $this->user = User::factory()->create();
        $this->user->givePermissionTo('posts.create');
    });

    it('validates required fields', function () {
        Livewire::actingAs($this->user)
            ->test(CreatePost::class)
            ->call('save')
            ->assertHasErrors(['title' => 'required', 'content' => 'required']);
    });

    it('creates a post with valid data', function () {
        Livewire::actingAs($this->user)
            ->test(CreatePost::class)
            ->set('title', 'My New Post')
            ->set('content', 'Post content here')
            ->set('status', 'draft')
            ->call('save')
            ->assertHasNoErrors()
            ->assertRedirect();

        $this->assertDatabaseHas('posts', [
            'title' => 'My New Post',
        ]);
    });

    it('shows validation errors in real-time', function () {
        Livewire::actingAs($this->user)
            ->test(CreatePost::class)
            ->set('title', 'ab') // Too short
            ->assertHasErrors(['title' => 'min']);
    });
});

API Tests

REST API Testing

<?php

use App\Models\User;
use App\Models\Post;
use Laravel\Sanctum\Sanctum;

describe('Post API', function () {
    beforeEach(function () {
        $this->user = User::factory()->create();
        $this->user->givePermissionTo([
            'posts.view',
            'posts.create',
            'posts.edit',
            'posts.delete',
        ]);
    });

    it('lists posts', function () {
        Sanctum::actingAs($this->user);

        Post::factory()->count(3)->create();

        $this->getJson('/api/v1/posts')
            ->assertOk()
            ->assertJsonCount(3, 'data')
            ->assertJsonStructure([
                'data' => [
                    '*' => ['id', 'title', 'slug', 'status'],
                ],
                'meta' => ['current_page', 'total'],
            ]);
    });

    it('creates a post via API', function () {
        Sanctum::actingAs($this->user);

        $this->postJson('/api/v1/posts', [
            'title' => 'API Post',
            'content' => 'Created via API',
            'status' => 'draft',
        ])
            ->assertCreated()
            ->assertJsonPath('data.title', 'API Post');

        $this->assertDatabaseHas('posts', ['title' => 'API Post']);
    });

    it('returns validation errors', function () {
        Sanctum::actingAs($this->user);

        $this->postJson('/api/v1/posts', [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['title', 'content']);
    });

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

    it('respects rate limits', function () {
        Sanctum::actingAs($this->user);

        // Make many requests
        for ($i = 0; $i < 61; $i++) {
            $response = $this->getJson('/api/v1/posts');
        }

        $response->assertStatus(429); // Too Many Requests
    });
});

Unit Tests

Service Tests

<?php

use App\Services\PostService;
use App\Services\SlugService;
use App\Models\Post;
use App\Models\User;

describe('PostService', function () {
    beforeEach(function () {
        $this->service = app(PostService::class);
        $this->user = User::factory()->create();
    });

    it('creates a post with generated slug', function () {
        $post = $this->service->create([
            'title' => 'My Test Post',
            'content' => 'Content here',
            'user_id' => $this->user->id,
        ]);

        expect($post->slug)->toBe('my-test-post');
    });

    it('generates unique slugs', function () {
        $post1 = $this->service->create([
            'title' => 'Same Title',
            'content' => 'Content',
            'user_id' => $this->user->id,
        ]);

        $post2 = $this->service->create([
            'title' => 'Same Title',
            'content' => 'Content',
            'user_id' => $this->user->id,
        ]);

        expect($post1->slug)->not->toBe($post2->slug);
        expect($post2->slug)->toContain('same-title-');
    });

    it('publishes a draft post', function () {
        $post = Post::factory()->create(['status' => 'draft']);

        $this->service->publish($post);

        expect($post->fresh()->status)->toBe('published');
        expect($post->fresh()->published_at)->not->toBeNull();
    });
});

Model Tests

<?php

use App\Models\Post;
use App\Models\User;

describe('Post Model', function () {
    it('belongs to a user', function () {
        $post = Post::factory()->create();

        expect($post->author)->toBeInstanceOf(User::class);
    });

    it('has published scope', function () {
        Post::factory()->create(['status' => 'published']);
        Post::factory()->create(['status' => 'draft']);

        expect(Post::published()->count())->toBe(1);
    });

    it('generates excerpt from content', function () {
        $post = Post::factory()->create([
            'content' => str_repeat('word ', 100),
        ]);

        expect(strlen($post->excerpt))->toBeLessThanOrEqual(160);
    });

    it('casts dates correctly', function () {
        $post = Post::factory()->create([
            'published_at' => '2024-01-15 10:00:00',
        ]);

        expect($post->published_at)->toBeInstanceOf(Carbon::class);
    });
});

Testing Helpers

Custom Assertions

// tests/Pest.php

expect()->extend('toBePublished', function () {
    return $this->toHaveProperty('status', 'published')
        ->toHaveProperty('published_at')
        ->and($this->value->published_at)->not->toBeNull();
});

// Usage
expect($post)->toBePublished();

Test Traits

// tests/Traits/CreatesUsers.php
trait CreatesUsers
{
    protected function createAdmin(): User
    {
        $user = User::factory()->create();
        $user->assignRole('admin');
        return $user;
    }

    protected function createEditor(): User
    {
        $user = User::factory()->create();
        $user->givePermissionTo(['posts.view', 'posts.create', 'posts.edit']);
        return $user;
    }
}

// Usage in tests
uses(CreatesUsers::class);

test('admin can manage users', function () {
    $admin = $this->createAdmin();
    // ...
});

Test Factories

Creating Factories

<?php

namespace Database\Factories;

use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    protected $model = Post::class;

    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'title' => fake()->sentence(),
            'slug' => fake()->slug(),
            'content' => fake()->paragraphs(3, true),
            'status' => 'draft',
            'published_at' => null,
        ];
    }

    public function published(): static
    {
        return $this->state(fn (array $attributes) => [
            'status' => 'published',
            'published_at' => now(),
        ]);
    }

    public function scheduled(): static
    {
        return $this->state(fn (array $attributes) => [
            'status' => 'scheduled',
            'published_at' => now()->addDay(),
        ]);
    }

    public function withCategories(int $count = 3): static
    {
        return $this->hasAttached(
            Term::factory()->count($count),
            [],
            'categories'
        );
    }
}

Using Factories

// Single model
$post = Post::factory()->create();

// With state
$post = Post::factory()->published()->create();

// Multiple
$posts = Post::factory()->count(5)->create();

// With relationships
$post = Post::factory()
    ->withCategories(3)
    ->create();

// With specific attributes
$post = Post::factory()->create([
    'title' => 'Specific Title',
]);

Best Practices

Test Organization

  1. One test file per class/feature
  2. Descriptive test names
  3. Group related tests with describe()
  4. Use beforeEach() for common setup

Test Data

  1. Use factories for model creation
  2. Don't rely on database seeders
  3. Clean up after tests (RefreshDatabase)
  4. Use realistic but minimal data

Assertions

  1. Test one thing per test
  2. Use specific assertions
  3. Assert both positive and negative cases
  4. Test edge cases

Performance

  1. Use RefreshDatabase over DatabaseMigrations
  2. Mock external services
  3. Use --parallel for faster runs
  4. Skip slow tests in CI with ->skip()

Module Testing

// modules/YourModule/tests/Feature/ItemTest.php

<?php

use Tests\TestCase;
use App\Models\User;
use Modules\YourModule\App\Models\Item;

uses(TestCase::class, RefreshDatabase::class);

beforeEach(function () {
    $this->user = User::factory()->create();
    $this->user->givePermissionTo('yourmodule.view');
});

test('can list items', function () {
    Item::factory()->count(3)->create();

    $this->actingAs($this->user)
        ->get('/admin/yourmodule/items')
        ->assertOk()
        ->assertSee('Items');
});

CI/CD Integration

GitHub Actions

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: xdebug

      - name: Install Dependencies
        run: composer install --no-interaction

      - name: Run Tests
        run: php artisan test --coverage --min=80

Next Steps

/