
Laravel Guru
If there's one thing I wish I had embraced earlier in my Laravel journey, it's testing. I used to think testing was just extra work that slowed down development. Boy, was I wrong! Testing has not only saved me countless hours of debugging but has also made me a more confident and better developer.
After writing thousands of tests across dozens of Laravel applications, I want to share the testing strategies and patterns that have proven most valuable in real-world projects.
Sure, everyone knows testing catches bugs, but the real benefits go much deeper:
Laravel's testing tools are incredibly powerful. The framework does the heavy lifting, so you can focus on writing meaningful tests rather than setup code.
Feature tests simulate real user interactions with your application. Here's how I typically structure them:
<?php namespace Tests\Feature; use App\Models\User; use App\Models\Post; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class PostManagementTest extends TestCase { use RefreshDatabase; public function test_authenticated_user_can_create_post() { // Arrange $user = User::factory()->create(); $postData = [ 'title' => 'My Awesome Post', 'content' => 'This is the content of my awesome post.', 'status' => 'published' ]; // Act $response = $this->actingAs($user) ->post('/posts', $postData); // Assert $response->assertStatus(302); // Redirect after creation $response->assertRedirect('/posts'); $this->assertDatabaseHas('posts', [ 'title' => 'My Awesome Post', 'user_id' => $user->id, 'status' => 'published' ]); // Test that slug was generated $post = Post::where('title', 'My Awesome Post')->first(); $this->assertEquals('my-awesome-post', $post->slug); } public function test_guest_cannot_create_post() { $response = $this->post('/posts', [ 'title' => 'Unauthorized Post' ]); $response->assertRedirect('/login'); $this->assertDatabaseMissing('posts', [ 'title' => 'Unauthorized Post' ]); } public function test_post_creation_validates_required_fields() { $user = User::factory()->create(); $response = $this->actingAs($user) ->post('/posts', []); // Empty data $response->assertSessionHasErrors(['title', 'content']); } public function test_user_can_view_their_posts() { $user = User::factory()->create(); $posts = Post::factory(3)->create(['user_id' => $user->id]); $otherUserPost = Post::factory()->create(); // Different user $response = $this->actingAs($user)->get('/posts'); $response->assertStatus(200); // Should see own posts foreach($posts as $post) { $response->assertSee($post->title); } // Should not see other user's post $response->assertDontSee($otherUserPost->title); } }
Unit tests focus on individual methods or classes. They're fast and pinpoint exactly what's broken:
<?php namespace Tests\Unit; use App\Models\Post; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class PostModelTest extends TestCase { use RefreshDatabase; public function test_post_generates_slug_from_title() { $post = new Post([ 'title' => 'This is a Test Post Title!' ]); $this->assertEquals('this-is-a-test-post-title', $post->slug); } public function test_post_slug_is_unique() { // Create first post Post::factory()->create([ 'title' => 'Duplicate Title', 'slug' => 'duplicate-title' ]); // Create second post with same title $secondPost = new Post([ 'title' => 'Duplicate Title' ]); $this->assertEquals('duplicate-title-1', $secondPost->slug); } public function test_post_belongs_to_user() { $user = User::factory()->create(); $post = Post::factory()->create(['user_id' => $user->id]); $this->assertInstanceOf(User::class, $post->author); $this->assertEquals($user->id, $post->author->id); } public function test_post_has_readable_published_date() { $post = Post::factory()->create([ 'published_at' => '2024-12-25 10:30:00' ]); $this->assertEquals('December 25, 2024', $post->readable_published_date); } public function test_post_scope_published_only_returns_published_posts() { Post::factory(2)->create(['status' => 'published']); Post::factory(3)->create(['status' => 'draft']); $publishedPosts = Post::published()->get(); $this->assertCount(2, $publishedPosts); $this->assertTrue($publishedPosts->every(fn($post) => $post->status === 'published')); } }
Model factories are a game-changer for testing. Here's how I use them effectively:
// database/factories/PostFactory.php class PostFactory extends Factory { public function definition() { return [ 'title' => $this->faker->sentence(4), 'content' => $this->faker->paragraphs(3, true), 'status' => 'published', 'user_id' => User::factory(), 'published_at' => $this->faker->dateTimeBetween('-1 year', 'now'), ]; } public function draft() { return $this->state(fn (array $attributes) => [ 'status' => 'draft', 'published_at' => null, ]); } public function withCategory(string $category) { return $this->state(fn (array $attributes) => [ 'category' => $category, ]); } } // Usage in tests $publishedPosts = Post::factory(5)->create(); $draftPosts = Post::factory(3)->draft()->create(); $tutorialPosts = Post::factory(2)->withCategory('tutorial')->create();
public function test_api_returns_paginated_posts() { Post::factory(25)->create(['status' => 'published']); $response = $this->getJson('/api/posts'); $response->assertStatus(200) ->assertJsonStructure([ 'data' => [ '*' => ['id', 'title', 'excerpt', 'published_at'] ], 'links', 'meta' ]) ->assertJsonCount(15, 'data'); // Default pagination } public function test_api_post_creation_requires_authentication() { $response = $this->postJson('/api/posts', [ 'title' => 'Unauthorized Post' ]); $response->assertStatus(401); } public function test_api_validates_post_data() { $user = User::factory()->create(); $response = $this->actingAs($user, 'sanctum') ->postJson('/api/posts', [ 'title' => '', // Invalid 'content' => 'Short' // Too short ]); $response->assertStatus(422) ->assertJsonValidationErrors(['title', 'content']); }
use Illuminate\Support\Facades\Queue; use App\Jobs\ProcessNewPost; public function test_new_post_queues_processing_job() { Queue::fake(); $user = User::factory()->create(); $this->actingAs($user) ->post('/posts', [ 'title' => 'New Post', 'content' => 'Post content' ]); Queue::assertPushed(ProcessNewPost::class, function ($job) { return $job->post->title === 'New Post'; }); }
use Illuminate\Support\Facades\Event; use App\Events\PostPublished; public function test_publishing_post_fires_event() { Event::fake(); $post = Post::factory()->draft()->create(); $post->update(['status' => 'published']); Event::assertDispatched(PostPublished::class, function ($event) use ($post) { return $event->post->id === $post->id; }); }
use Illuminate\Foundation\Testing\DatabaseTransactions; class FastDatabaseTest extends TestCase { use DatabaseTransactions; // Faster than RefreshDatabase // Your tests here }
public function test_complex_query_performance() { // Create realistic data volume User::factory(100)->create(); Post::factory(1000)->create(); $startTime = microtime(true); $popularPosts = Post::withCount('likes') ->having('likes_count', '>', 10) ->orderBy('likes_count', 'desc') ->take(10) ->get(); $executionTime = microtime(true) - $startTime; $this->assertLessThan(0.1, $executionTime); // Should execute in under 100ms $this->assertGreaterThan(0, $popularPosts->count()); }
Always structure tests with Arrange, Act, Assert:
public function test_user_can_delete_own_post() { // Arrange $user = User::factory()->create(); $post = Post::factory()->create(['user_id' => $user->id]); // Act $response = $this->actingAs($user)->delete("/posts/{$post->id}"); // Assert $response->assertStatus(204); $this->assertSoftDeleted('posts', ['id' => $post->id]); }
// Good public function test_user_cannot_delete_post_they_do_not_own() // Bad public function test_delete_post()
// Instead of this public function test_post_creation() { $response = $this->post('/posts', $data); $response->assertStatus(201); $this->assertDatabaseHas('posts', $data); $response->assertJsonStructure(['id', 'title']); } // Do this public function test_post_creation_returns_created_status() { /* ... */ } public function test_post_creation_saves_to_database() { /* ... */ } public function test_post_creation_returns_correct_json() { /* ... */ }
/** * @dataProvider invalidPostDataProvider */ public function test_post_validation_fails_with_invalid_data($invalidData, $expectedErrors) { $user = User::factory()->create(); $response = $this->actingAs($user)->post('/posts', $invalidData); $response->assertSessionHasErrors($expectedErrors); } public function invalidPostDataProvider() { return [ 'missing title' => [ ['content' => 'Some content'], ['title'] ], 'missing content' => [ ['title' => 'Some title'], ['content'] ], 'title too long' => [ ['title' => str_repeat('a', 256), 'content' => 'Content'], ['title'] ], ]; }
// Instead of PHPUnit class PostTest extends TestCase { public function test_post_has_slug() { $post = Post::factory()->create(['title' => 'Test Title']); $this->assertEquals('test-title', $post->slug); } } // Use Pest it('generates slug from title', function () { $post = Post::factory()->create(['title' => 'Test Title']); expect($post->slug)->toBe('test-title'); });
public function test_user_can_create_post_through_ui() { $user = User::factory()->create(); $this->browse(function (Browser $browser) use ($user) { $browser->loginAs($user) ->visit('/posts/create') ->type('title', 'My New Post') ->type('content', 'This is my post content') ->press('Publish') ->assertPathIs('/posts') ->assertSee('My New Post'); }); }
// Bad - testing implementation public function test_post_uses_correct_slug_generation_method() { $post = new Post(); $this->assertTrue(method_exists($post, 'generateSlug')); } // Good - testing behavior public function test_post_has_url_friendly_slug() { $post = Post::factory()->create(['title' => 'Hello World!']); $this->assertEquals('hello-world', $post->slug); }
public function test_post_slug_handles_special_characters() { $post = Post::factory()->create(['title' => 'Post with émojis! 🚀 & símböls']); $this->assertMatchesRegularExpression('/^[a-z0-9-]+$/', $post->slug); }
// Slow - creates users in every test class SlowTest extends TestCase { public function test_feature_one() { $user = User::factory()->create(); // Database hit // Test logic } } // Fast - use setUpOnce or shared fixtures class FastTest extends TestCase { private static $user; public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); static::$user = User::factory()->create(); } }
I track these metrics to ensure my tests are actually valuable:
Testing isn't just about catching bugs – it's about building confidence in your code and enabling rapid, fearless development. Yes, it takes time upfront, but it pays dividends in the long run.
Start small. Pick one feature and write comprehensive tests for it. Once you experience the confidence that comes from a well-tested codebase, you'll never want to go back.
What's your biggest testing challenge? Are you struggling with slow tests, flaky tests, or just getting started? I'd love to help you work through it!

Master complex database relationships in Laravel with polymorphic relations, eager loading optimization, and advanced query techniques.

Learn how to create secure, scalable APIs using Laravel Sanctum for authentication and authorization in modern web applications.

Explore the power of Laravel Blade components for creating reusable, maintainable UI elements in your applications.