Laravel Testing: PHPUnit and Pest
Laravel Testing: Tips & Tricks
Laravel Testing: Tips & Tricks
Tip: Use assertDatabaseCount() Instead of count()
// Bad
$this->assertEquals(5, Post::count());
// Good
$this->assertDatabaseCount('posts', 5);
It's more readable and doesn't load models into memory.
Gotcha: RefreshDatabase is Slow
RefreshDatabase migrates the entire database for each test class. For large test suites, use DatabaseTransactions instead (but it doesn't work with queued jobs).
Tip: Test the Happy Path First
it('creates a post', function () {
$response = $this->post('/posts', ['title' => 'Test']);
$response->assertRedirect();
$this->assertDatabaseHas('posts', ['title' => 'Test']);
});
Then test edge cases.
Tip: Use actingAs() for Auth Tests
$user = User::factory()->create();
$this->actingAs($user)->get('/dashboard')->assertOk();
Gotcha: Mocking External Services
Don't hit real APIs in tests. Use Http::fake():
Http::fake(['api.example.com/*' => Http::response(['ok' => true])]);
Tip: Test Email with Mail::fake()
Mail::fake();
$this->post('/register', ['email' => 'test@test.com']);
Mail::assertSent(WelcomeEmail::class);
Mail::assertSent(WelcomeEmail::class, fn($mail) => $mail->hasTo('test@test.com'));
Tip: Use assertSee() Carefully
// Checks HTML content
$response->assertSee('Hello');
// Checks exact text, ignoring HTML
$response->assertSee('Hello', false);
Gotcha: Time-Sensitive Tests
Use Carbon::setTestNow() for consistent time-based tests:
Carbon::setTestNow('2024-01-15');
// All `now()` calls return this date
Carbon::setTestNow(); // Reset
Tip: Use route:cache Carefully
php artisan route:cache is fast, but it doesn't work with closure-based routes. Every time you cache routes, Laravel serializes them. If you have Route::redirect() or closure callbacks, the cache breaks. Stick to controller-based routes in production.
Tip: Model APP_KEY Rotation
Rotating APP_KEY invalidates all encrypted data — cookies, encrypted DB columns, and password reset tokens. If you must rotate (e.g., after a leak), plan a migration that re-encrypts existing data with the new key.
Gotcha: Local Scope Leaks
Global scopes defined in booted() apply to ALL queries on that model — including relationships. An innocent User::all() in admin panel might exclude soft-deleted users if a global scope is active.
Senior Insight
After reviewing hundreds of PRs, the single quality signal I look for is whether tests cover the unhappy path. Most developers write the 'happy path' test and call it done. In production, it's the edge cases — database deadlocks, validation errors, expired tokens — that bring systems down. I've adopted mutation testing as a way to measure test quality, and it consistently reveals gaps that code coverage metrics miss.
Source: Laravel News (https://laravel-news.com/), Freek.dev (https://freek.dev/tags/laravel), Spatie Blog (https://spatie.be/blog)