Testing Guide
Comprehensive guide to testing Core PHP Framework applications.
Running Tests
bash
# Run all tests
composer test
# Run specific test file
./vendor/bin/phpunit packages/core-php/tests/Feature/ActivityLogServiceTest.php
# Run tests with coverage
./vendor/bin/phpunit --coverage-html coverage
# Run specific test method
./vendor/bin/phpunit --filter test_creates_postTest Structure
tests/
├── Feature/ # Integration tests
│ ├── ApiTest.php
│ ├── AuthTest.php
│ └── PostTest.php
├── Unit/ # Unit tests
│ ├── ActionTest.php
│ └── ServiceTest.php
└── TestCase.php # Base test caseWriting Feature Tests
Feature tests test complete workflows:
php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Mod\Blog\Models\Post;
use Mod\Tenant\Models\User;
class PostTest extends TestCase
{
public function test_user_can_create_post(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/posts', [
'title' => 'Test Post',
'content' => 'Test content',
'status' => 'draft',
]);
$response->assertRedirect();
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'author_id' => $user->id,
]);
}
public function test_guest_cannot_create_post(): void
{
$response = $this->post('/posts', [
'title' => 'Test Post',
'content' => 'Test content',
]);
$response->assertRedirect(route('login'));
}
public function test_user_can_view_own_posts(): void
{
$user = User::factory()->create();
$post = Post::factory()->create(['author_id' => $user->id]);
$response = $this->actingAs($user)
->get("/posts/{$post->id}");
$response->assertOk();
$response->assertSee($post->title);
}
}Writing Unit Tests
Unit tests test isolated components:
php
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Mod\Blog\Actions\CreatePost;
use Mod\Blog\Models\Post;
class CreatePostTest extends TestCase
{
public function test_creates_post(): void
{
$post = CreatePost::run([
'title' => 'Test Post',
'content' => 'Test content',
'status' => 'draft',
]);
$this->assertInstanceOf(Post::class, $post);
$this->assertEquals('Test Post', $post->title);
$this->assertDatabaseHas('posts', ['id' => $post->id]);
}
public function test_generates_slug_from_title(): void
{
$post = CreatePost::run([
'title' => 'Test Post',
'content' => 'Content',
]);
$this->assertEquals('test-post', $post->slug);
}
public function test_throws_exception_for_invalid_data(): void
{
$this->expectException(ValidationException::class);
CreatePost::run([
'title' => '', // Invalid
'content' => 'Content',
]);
}
}Database Testing
Factories
php
<?php
namespace Mod\Blog\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => $this->faker->sentence(),
'content' => $this->faker->paragraphs(3, true),
'status' => 'draft',
'author_id' => User::factory(),
];
}
public function published(): self
{
return $this->state([
'status' => 'published',
'published_at' => now(),
]);
}
public function draft(): self
{
return $this->state(['status' => 'draft']);
}
}Usage:
php
// Create single post
$post = Post::factory()->create();
// Create published post
$post = Post::factory()->published()->create();
// Create multiple posts
$posts = Post::factory()->count(10)->create();
// Create with specific attributes
$post = Post::factory()->create([
'title' => 'Specific Title',
]);Database Assertions
php
// Assert record exists
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'status' => 'published',
]);
// Assert record doesn't exist
$this->assertDatabaseMissing('posts', [
'title' => 'Deleted Post',
]);
// Assert record count
$this->assertDatabaseCount('posts', 10);
// Assert model exists
$this->assertModelExists($post);
// Assert model deleted
$this->assertSoftDeleted($post);API Testing
php
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Laravel\Sanctum\Sanctum;
class PostApiTest extends TestCase
{
public function test_lists_posts(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:read']);
Post::factory()->count(5)->published()->create();
$response = $this->getJson('/api/v1/posts');
$response->assertOk();
$response->assertJsonCount(5, 'data');
$response->assertJsonStructure([
'data' => [
'*' => ['id', 'title', 'status', 'created_at'],
],
]);
}
public function test_creates_post(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:write']);
$response = $this->postJson('/api/v1/posts', [
'title' => 'API Test Post',
'content' => 'Test content',
]);
$response->assertCreated();
$response->assertJson([
'title' => 'API Test Post',
]);
$this->assertDatabaseHas('posts', [
'title' => 'API Test Post',
]);
}
public function test_requires_authentication(): void
{
$response = $this->getJson('/api/v1/posts');
$response->assertUnauthorized();
}
public function test_requires_correct_scope(): void
{
$user = User::factory()->create();
Sanctum::actingAs($user, ['posts:read']); // Missing write scope
$response = $this->postJson('/api/v1/posts', [
'title' => 'Test',
'content' => 'Content',
]);
$response->assertForbidden();
}
}Livewire Testing
php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Livewire\Livewire;
use Mod\Blog\View\Modal\Admin\PostEditor;
class PostEditorTest extends TestCase
{
public function test_renders_post_editor(): void
{
$post = Post::factory()->create();
Livewire::test(PostEditor::class, ['post' => $post])
->assertSee($post->title)
->assertSee('Save');
}
public function test_updates_post(): void
{
$post = Post::factory()->create(['title' => 'Original']);
Livewire::test(PostEditor::class, ['post' => $post])
->set('title', 'Updated Title')
->call('save')
->assertDispatched('post-updated');
$this->assertEquals('Updated Title', $post->fresh()->title);
}
public function test_validates_input(): void
{
$post = Post::factory()->create();
Livewire::test(PostEditor::class, ['post' => $post])
->set('title', '')
->call('save')
->assertHasErrors(['title' => 'required']);
}
}Mocking
Mocking Services
php
use Mockery;
use Mod\Payment\Services\PaymentService;
public function test_processes_order_with_mock(): void
{
$mock = Mockery::mock(PaymentService::class);
$mock->shouldReceive('charge')
->once()
->with(1000, 'GBP')
->andReturn(new PaymentResult(success: true));
$this->app->instance(PaymentService::class, $mock);
$order = Order::factory()->create();
$result = $this->orderService->process($order);
$this->assertTrue($result->success);
}Mocking Facades
php
use Illuminate\Support\Facades\Storage;
public function test_uploads_file(): void
{
Storage::fake('s3');
$this->post('/upload', [
'file' => UploadedFile::fake()->image('photo.jpg'),
]);
Storage::disk('s3')->assertExists('photos/photo.jpg');
}Mocking Events
php
use Illuminate\Support\Facades\Event;
use Mod\Blog\Events\PostPublished;
public function test_fires_event(): void
{
Event::fake([PostPublished::class]);
$post = Post::factory()->create();
$service->publish($post);
Event::assertDispatched(PostPublished::class, function ($event) use ($post) {
return $event->post->id === $post->id;
});
}Testing Workspace Isolation
php
public function test_scopes_to_workspace(): void
{
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
$post1 = Post::factory()->create(['workspace_id' => $workspace1->id]);
$post2 = Post::factory()->create(['workspace_id' => $workspace2->id]);
// Acting as user in workspace1
$user = User::factory()->create(['workspace_id' => $workspace1->id]);
$posts = Post::all(); // Should only see workspace1's posts
$this->assertCount(1, $posts);
$this->assertEquals($post1->id, $posts->first()->id);
}Best Practices
1. Test One Thing
php
// ✅ Good - tests one behavior
public function test_creates_post(): void
{
$post = CreatePost::run([...]);
$this->assertInstanceOf(Post::class, $post);
}
// ❌ Bad - tests multiple things
public function test_post_operations(): void
{
$post = CreatePost::run([...]);
$this->assertInstanceOf(Post::class, $post);
$post->publish();
$this->assertEquals('published', $post->status);
$post->delete();
$this->assertSoftDeleted($post);
}2. Use Descriptive Names
php
// ✅ Good
public function test_user_can_create_post_with_valid_data(): void
// ❌ Bad
public function test_create(): void3. Arrange, Act, Assert
php
public function test_publishes_post(): void
{
// Arrange
$post = Post::factory()->create(['status' => 'draft']);
$user = User::factory()->create();
// Act
$result = $service->publish($post, $user);
// Assert
$this->assertEquals('published', $result->status);
$this->assertNotNull($result->published_at);
}4. Clean Up After Tests
php
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase
{
use RefreshDatabase; // Resets database after each test
public function test_something(): void
{
// Test code
}
}