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
- One test file per class/feature
- Descriptive test names
- Group related tests with
describe() - Use
beforeEach()for common setup
Test Data
- Use factories for model creation
- Don't rely on database seeders
- Clean up after tests (RefreshDatabase)
- Use realistic but minimal data
Assertions
- Test one thing per test
- Use specific assertions
- Assert both positive and negative cases
- Test edge cases
Performance
- Use
RefreshDatabaseoverDatabaseMigrations - Mock external services
- Use
--parallelfor faster runs - 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
- Module Development - Test your modules
- API Development - API testing
- Hooks Reference - Test hooks