Laravel Testing with PHPUnit: Advanced Techniques, Mocks & Production Pitfalls
- Pure unit tests must extend PHPUnit\Framework\TestCase directly — extending Laravel's TestCase boots the full framework on every test and silently inflates your suite runtime by 10–50x for logic-only tests.
- DatabaseTransactions is almost always faster than RefreshDatabase for feature tests — use RefreshDatabase only when you're explicitly testing migration correctness or need a fully fresh schema each run.
- Always call Bus::fake(), Mail::fake(), and Http::fake() before executing the action under test — calling them after is a silent no-op because the real dispatcher already handled the dispatches.
- Feature tests: boot full Laravel app, test HTTP endpoints end-to-end, 50-200ms each
- Unit tests: extend PHPUnit directly, no framework overhead, under 1ms each
- Database strategies: RefreshDatabase (slow, schema-precise) vs DatabaseTransactions (fast, rollback-based)
- Side-effect control: Bus::fake(), Mail::fake(), Http::fake() intercept real dispatches
- Calling fakes AFTER the action is a silent no-op — the real dispatcher already ran
- Tests that only assert status codes are false safety nets
- Extending Laravel's TestCase for pure logic tests. You pay the full framework bootstrap cost on every test. A 500-test suite crawls to 3+ minutes instead of under 30 seconds.
Test suite too slow (8+ minutes).
php artisan test --profile --order-by=defectsphp artisan test --parallel --processes=4Tests pass locally but fail in CI.
php artisan test --shuffle --reorder=randomphp artisan test --filter=FailingTest --verboseBus::assertDispatched() fails but jobs appear in logs.
grep -n 'Bus::fake' tests/Feature/YourTest.phpphp artisan test --filter=YourTest --verboseDatabase constraint violations in parallel tests.
php artisan test --parallel --processes=4 --verbosecat tests/TestCase.php | grep -i 'database'assertDatabaseHas() passes but the feature is actually broken.
php artisan test --filter=YourTest --verbosephp artisan tinker --execute="App\Models\YourModel::latest()->first()"Production Incident
Production Debug GuideSymptom-first investigation path for test suite problems.
Shipping Laravel code without a test suite is like pushing a database migration to production and hoping for the best — technically possible, professionally reckless. At scale, a single untested service class can cascade into data corruption, failed payments, or silent queue failures that nobody notices until a client calls.
The problem most teams hit isn't that they don't know PHPUnit exists — it's that their tests are brittle, slow, or don't actually prove anything useful. They mock too much, hit the real database when they shouldn't, ignore queue and event side-effects, or write assertions so loose that a broken feature still produces a green tick.
This isn't a beginner's tour. It covers structuring a real-world test suite with proper isolation strategies, mocking Eloquent and external services cleanly, faking queues and events to assert side-effects, and diagnosing the performance and reliability problems that silently rot test suites over time.
Feature Tests vs Unit Tests — Choosing the Right Weapon
Laravel ships with two test base classes: Tests\TestCase (feature) and PHPUnit\Framework\TestCase (pure unit). The distinction isn't cosmetic — it determines what Laravel bootstraps, what performance you pay, and what you can actually assert.
A feature test boots the full Laravel application: service providers fire, middleware runs, the IoC container resolves real bindings, and your HTTP kernel processes the request just as Nginx would hand it off. This gives you end-to-end confidence but costs 50–200ms per test depending on your provider stack.
A pure unit test extends PHPUnit directly. No service container, no database, no config loading. It's testing a PHP class in total isolation — a domain service, a value object, a complex calculation. These run in under 1ms each and should make up the bulk of your test count.
The production gotcha most teams miss: writing 'unit tests' that extend Laravel's TestCase. They get the service container for free but pay the full bootstrap cost on every single test, making a 500-test suite crawl to 3+ minutes. Identify your pure logic classes and pull them into real unit tests. Your CI pipeline will thank you.
<?php // Pure unit test — extends PHPUnit directly, zero Laravel overhead // Use this for: domain logic, value objects, utility classes namespace Tests\Unit; use PHPUnit\Framework\TestCase; use App\Domain\Pricing\OrderCalculator; use App\Domain\Pricing\DiscountRule; class OrderCalculatorTest extends TestCase { private OrderCalculator $calculator; protected function setUp(): void { parent::setUp(); // No app()->make() here — we new up the class directly. // This forces good design: if you can't new it up, it has too many dependencies. $this->calculator = new OrderCalculator(); } /** @test */ public function it_applies_percentage_discount_before_tax(): void { // Arrange: build a 20% discount rule $discountRule = new DiscountRule(type: 'percentage', value: 20.0); // Act: calculate a $100 order with 10% tax $result = $this->calculator->calculate( subtotalCents: 10000, // $100.00 in cents — always store money as integers taxRatePercent: 10.0, discountRule: $discountRule ); // Assert: discount reduces subtotal FIRST, then tax is applied on discounted amount // $100 - 20% = $80, then $80 * 1.10 = $88.00 $this->assertSame(8800, $result->totalCents); // $88.00 $this->assertSame(2000, $result->discountCents); // $20.00 saved $this->assertSame(800, $result->taxCents); // tax on $80, not $100 } /** @test */ public function it_throws_when_discount_exceeds_subtotal(): void { $discountRule = new DiscountRule(type: 'fixed', value: 15000); // $150 off a $100 order // Expect a domain exception — don't let business rule violations become silent null returns $this->expectException(\App\Domain\Pricing\InvalidDiscountException::class); $this->expectExceptionMessage('Discount cannot exceed order subtotal'); $this->calculator->calculate( subtotalCents: 10000, taxRatePercent: 10.0, discountRule: $discountRule ); } }
.. 2 / 2 (100%)
Time: 00:00.008, Memory: 8.00 MB
OK (2 tests, 4 assertions)
php artisan test --profile and check the time per test. Anything over 20ms in a 'unit' test is almost certainly booting the framework. Extend PHPUnit\Framework\TestCase directly for pure logic — you'll cut suite runtime by 60–80% on large codebases.Database Testing Strategies — Transactions, Factories & Isolation That Actually Works
Database testing is where most Laravel test suites quietly fall apart. The default RefreshDatabase trait drops and recreates your entire schema on every test run using migrations. That's fine locally — it takes 2–4 seconds. In CI with 300 tests, it's a minutes-long bottleneck. Worse, teams often use it without understanding what it buys them versus the DatabaseTransactions trait.
RefreshDatabase re-runs all migrations from scratch each suite run, giving you a pristine schema that mirrors production exactly. Use it when: you're testing migration correctness itself, or your schema changes frequently and you need to catch broken migrations early.
DatabaseTransactions wraps each test in an open transaction that rolls back after the test. No migrations, no table drops — just a rollback. This is 10–50x faster per test. The catch: anything that commits inside the test (think: DB::statement() with DDL, or code that opens its own connection via queue workers) won't be rolled back. External processes never see the uncommitted data.
Model factories are your best tool for readable, maintainable seed data. But advanced teams go further: they build factory states that encode real business scenarios, not just random attribute overrides. A ->suspended() state on UserFactory is infinitely more readable in a test than a raw ['status' => 'suspended', 'suspended_at' => .now()]
<?php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Models\User; use App\Models\Subscription; use App\Models\Plan; use Laravel\Sanctum\Sanctum; // RefreshDatabase chosen here because we test a migration-dependent JSON column // For simpler CRUD tests, swap this for DatabaseTransactions for a speed boost class SubscriptionFeatureTest extends TestCase { use RefreshDatabase; private Plan $premiumPlan; protected function setUp(): void { parent::setUp(); // Seed only the data EVERY test in this class needs. // Don't call $this->seed() blindly — it runs ALL seeders and slows everything down. $this->premiumPlan = Plan::factory()->premium()->create(); } /** @test */ public function active_subscriber_can_access_premium_content(): void { $subscriber = User::factory() ->withActiveSubscription($this->premiumPlan) ->create(); Sanctum::actingAs($subscriber); $response = $this->getJson('/api/content/premium-report'); $response ->assertOk() ->assertJsonStructure([ 'data' => ['report_id', 'title', 'content', 'published_at'] ]) ->assertJsonPath('data.report_id', fn($id) => is_int($id) && $id > 0); } /** @test */ public function suspended_subscriber_receives_payment_required_response(): void { $suspendedUser = User::factory() ->withActiveSubscription($this->premiumPlan) ->suspended() ->create(); Sanctum::actingAs($suspendedUser); $response = $this->getJson('/api/content/premium-report'); $response ->assertStatus(402) ->assertJsonPath('error.code', 'SUBSCRIPTION_SUSPENDED') ->assertJsonMissing(['data']); } /** @test */ public function subscription_expiry_date_is_stored_as_utc(): void { $user = User::factory()->create(); $expiresAt = now('America/New_York')->addMonth(); $subscription = Subscription::factory()->create([ 'user_id' => $user->id, 'plan_id' => $this->premiumPlan->id, 'expires_at' => $expiresAt, ]); $subscription->refresh(); $this->assertSame( $expiresAt->utc()->toDateTimeString(), $subscription->expires_at->utc()->toDateTimeString() ); } }
... 3 / 3 (100%)
Time: 00:02.341, Memory: 32.00 MB
OK (3 tests, 9 assertions)
- RefreshDatabase: re-runs all migrations. 2-5s overhead per suite. Use for migration testing.
- DatabaseTransactions: wraps test in rollback transaction. Microsecond overhead. Default choice.
- Factory states encode business scenarios. ->suspended() is more readable than raw attributes.
- assertJsonPath with closures: assert structure and value range in one line.
- Never call $this->seed() in every test. Use targeted factories for speed.
Mocking Services, Faking Queues & Testing Side-Effects Without Pain
The single biggest source of flaky, slow tests is side-effects: emails sent, queues dispatched, Stripe charges attempted, Slack messages fired. Laravel's Bus::fake(), Mail::fake(), Event::fake(), and Queue::fake() facades exist for exactly this reason — they swap the real implementation with an in-memory spy that records everything dispatched without doing any of it.
The subtle but critical rule: call Bus::fake() or Mail::fake() before the code under test runs. Laravel replaces the binding in the container at that moment. Calling it after your action is an assertion-free no-op — the real job already dispatched.
For external HTTP services (payment processors, shipping APIs, OAuth providers), never let real HTTP calls happen in tests. Use Http::fake() to intercept Guzzle under the hood. This lets you test both happy paths and the far more important failure paths — 429 rate limit responses, 503 timeouts, malformed JSON — without needing a staging environment or real credentials.
For Eloquent models with complex relationships, prefer constructor injection over in your services, then pass Mockery mocks directly. This makes the test's contract explicit: 'this service needs something that looks like a UserRepository' — not 'this service needs the real database'.app()->make()
<?php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Http; use App\Models\User; use App\Models\Product; use App\Jobs\ProcessPayment; use App\Jobs\ReserveInventory; use App\Mail\OrderConfirmation; class OrderPlacementTest extends TestCase { use RefreshDatabase; /** @test */ public function placing_an_order_dispatches_payment_and_inventory_jobs_in_correct_order(): void { // CRITICAL: Fake BEFORE the action — not after. Bus::fake(); Mail::fake(); Http::fake([ 'api.stripe.com/*' => Http::response([ 'id' => 'pi_test_abc123', 'status' => 'succeeded', ], 200), ]); $buyer = User::factory()->withVerifiedEmail()->create(); $laptop = Product::factory()->inStock(quantity: 5)->create(['price_cents' => 149999]); $response = $this->actingAs($buyer)->postJson('/api/orders', [ 'items' => [ ['product_id' => $laptop->id, 'quantity' => 1], ], 'payment_method' => 'pm_test_visa', ]); $response->assertCreated(); Bus::assertDispatched(ProcessPayment::class, function (ProcessPayment $job) use ($buyer) { return $job->userId === $buyer->id && $job->paymentMethod === 'pm_test_visa'; }); Bus::assertDispatched(ReserveInventory::class, function (ReserveInventory $job) use ($laptop) { return $job->productId === $laptop->id && $job->quantity === 1; }); Bus::assertDispatchedTimes(ProcessPayment::class, 1); Bus::assertDispatchedTimes(ReserveInventory::class, 1); Mail::assertQueued(OrderConfirmation::class, function (OrderConfirmation $mail) use ($buyer) { return $mail->hasTo($buyer->email); }); Http::assertSent(function ($request) { return str_contains($request->url(), 'api.stripe.com') && $request->data()['payment_method'] === 'pm_test_visa'; }); } /** @test */ public function order_placement_does_not_dispatch_jobs_when_stripe_returns_card_declined(): void { Bus::fake(); Mail::fake(); Http::fake([ 'api.stripe.com/*' => Http::response([ 'error' => ['code' => 'card_declined', 'message' => 'Your card was declined.'] ], 402), ]); $buyer = User::factory()->withVerifiedEmail()->create(); $laptop = Product::factory()->inStock(quantity: 5)->create(['price_cents' => 149999]); $response = $this->actingAs($buyer)->postJson('/api/orders', [ 'items' => [['product_id' => $laptop->id, 'quantity' => 1]], 'payment_method' => 'pm_test_declined', ]); $response ->assertUnprocessable() ->assertJsonPath('error.code', 'CARD_DECLINED'); Bus::assertNothingDispatched(); Mail::assertNothingQueued(); } }
.. 2 / 2 (100%)
Time: 00:00.847, Memory: 28.00 MB
OK (2 tests, 12 assertions)
- Bus::fake(): intercepts ->dispatch(),
dispatch(), and chained/batched jobs. Rich assertion API. - Queue::fake(): intercepts raw Queue::push(). No chaining or batching support.
- Mail::fake(): intercepts all Mailable dispatches. Use assertQueued() not assertSent() for queued mail.
- Http::fake(): intercepts Guzzle calls. Define response patterns per URL glob.
- Event::fake(): intercepts all event dispatches. Use assertDispatched() to verify specific events.
Testing Artisan Commands, Middleware & Authorization Policies
Console commands, middleware, and authorization policies are three areas where coverage is thin in most codebases — and where bugs in production are disproportionately painful. A broken middleware silently lets unauthorized users through. A buggy policy throws a 500 instead of a 403. A misconfigured Artisan command corrupts scheduled data quietly at 3am.
Testing Artisan commands with $this->artisan() gives you a fluent interface to assert exit codes, output text, and even interact with command->ask() prompts. For commands with database side-effects, combine with assertDatabaseHas to verify the outcome, not just the output string.
For middleware, don't test it indirectly through 20 layers of feature test. Write a focused feature test that hits a route with the middleware applied, controls the request state (headers, session, auth), and asserts the exact HTTP outcome. Test the middleware in isolation by registering a test-only route in setUp().
Authorization policies tested through the Gate facade are fast and surgical. Use $this->actingAs($user) with $this->assertTrue(Gate::allows('update', $resource)) rather than routing through HTTP — you get the policy logic tested without the controller overhead.
<?php namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Gate; use App\Models\User; use App\Models\Article; class ArticlePolicyTest extends TestCase { use RefreshDatabase; /** @test */ public function author_can_update_their_own_article(): void { $author = User::factory()->create(); $article = Article::factory()->for($author, 'author')->create(); $this->actingAs($author); $this->assertTrue( Gate::allows('update', $article), 'The author should always be allowed to update their own article' ); } /** @test */ public function editor_role_can_update_any_article(): void { $editor = User::factory()->withRole('editor')->create(); $author = User::factory()->create(); $article = Article::factory()->for($author, 'author')->create(); $this->actingAs($editor); $this->assertTrue(Gate::allows('update', $article)); } /** @test */ public function random_user_cannot_update_someone_elses_article(): void { $owner = User::factory()->create(); $interloper = User::factory()->create(); $article = Article::factory()->for($owner, 'author')->create(); $this->actingAs($interloper); $this->assertFalse(Gate::allows('update', $article)); $response = $this->patchJson("/api/articles/{$article->id}", [ 'title' => 'Hijacked Title', ]); $response->assertForbidden(); } /** @test */ public function it_does_not_leak_article_existence_to_unauthorized_users(): void { $owner = User::factory()->create(); $draftArticle = Article::factory()->for($owner, 'author')->draft()->create(); $visitor = User::factory()->create(); $this->actingAs($visitor); $this->getJson("/api/articles/{$draftArticle->id}")->assertNotFound(); } } namespace Tests\Feature; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use App\Models\Subscription; class ExpireSubscriptionsCommandTest extends TestCase { use RefreshDatabase; /** @test */ public function expire_subscriptions_command_marks_overdue_subscriptions_as_expired(): void { $expiredOne = Subscription::factory()->expiredYesterday()->create(); $expiredTwo = Subscription::factory()->expiredLastWeek()->create(); $activeOne = Subscription::factory()->expiresNextMonth()->create(); $this->artisan('subscriptions:expire') ->expectsOutput('Processing expired subscriptions...') ->expectsOutput('2 subscription(s) marked as expired.') ->assertExitCode(0); $this->assertDatabaseHas('subscriptions', [ 'id' => $expiredOne->id, 'status' => 'expired', ]); $this->assertDatabaseHas('subscriptions', [ 'id' => $expiredTwo->id, 'status' => 'expired', ]); $this->assertDatabaseHas('subscriptions', [ 'id' => $activeOne->id, 'status' => 'active', ]); } }
..... 5 / 5 (100%)
Time: 00:01.203, Memory: 30.00 MB
OK (5 tests, 11 assertions)
Test Parallelism, Performance Tuning & CI Pipeline Optimization
A test suite that takes 8 minutes destroys developer flow. Engineers stop running tests locally, push broken code, and rely on CI to catch failures — which now takes 8 minutes per feedback loop. The compounding effect is catastrophic: broken builds pile up, merge queues stall, and the team develops a culture of 'tests are optional'.
Laravel supports parallel testing via php artisan test --parallel. Under the hood, it uses Paratest to fork multiple PHP processes, each running a subset of test classes. The key constraint: each worker needs its own database to avoid transaction interference. Laravel's ParallelTesting facade handles this automatically for SQLite, but MySQL and PostgreSQL require manual per-worker database creation.
Beyond parallelism, the highest-impact optimizations are: converting fake-unit-tests to real unit tests (60-80% runtime reduction), swapping RefreshDatabase for DatabaseTransactions (10-50x per-test speedup), and using targeted factories instead of full seeders.
<?php namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\ParallelTesting; class TestCase extends BaseTestCase { use CreatesApplication; protected function setUp(): void { parent::setUp(); // Default: fake all side-effects in every feature test. // Individual tests can override by calling ->withoutFakes(). Bus::fake(); Mail::fake(); Event::fake(); Http::fake(); } protected function tearDown(): void { // Flush all fakes between tests to prevent pollution. // Without this, a fake registered in test A bleeds into test B. Bus::fake()->flush(); Mail::fake()->flush(); Event::fake()->flush(); Http::fake()->flush(); parent::tearDown(); } }
- Under 3 seconds: developer runs tests on every save. Maximum feedback loop.
- 3-30 seconds: developer runs tests before commit. Acceptable for pre-commit hooks.
- 30-90 seconds: developer runs tests before push. Push-based feedback loop.
- Over 5 minutes: CI only. Developers will not run locally. Expect broken builds.
- Target: full suite under 90 seconds with --parallel. Individual class under 3 seconds.
Testing Eloquent Relationships, Scopes & Complex Queries
Eloquent relationships are the most undertested part of most Laravel applications. Teams test that an endpoint returns 200, but never verify that the loaded relationships are correct, that scopes filter properly, or that eager loading prevents N+1 queries.
Testing relationships directly through the model (not through HTTP) is faster and more precise. Assert that a belongsTo returns the correct parent, that a hasMany returns only the expected children, and that a pivot table is populated correctly for many-to-many relationships.
Scopes (global and local) are a common source of silent bugs. A global scope that filters soft-deleted records might accidentally exclude records in a report query. Test scopes by creating records that should be included and excluded, then asserting the query result set.
N+1 queries are a production performance killer that tests can catch. Laravel's withoutExceptionHandling() combined with a query counter lets you assert that a relationship is eager-loaded, not lazy-loaded.
<?php namespace Tests\Unit; use Tests\TestCase; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use App\Models\Order; use App\Models\OrderItem; use App\Models\Product; use App\Models\User; class OrderRelationshipTest extends TestCase { use RefreshDatabase; /** @test */ public function order_has_many_items(): void { $order = Order::factory()->create(); $items = OrderItem::factory()->count(3)->for($order)->create(); // Assert the relationship returns all items $this->assertCount(3, $order->items); $this->assertTrue($order->items->contains($items->first())); } /** @test */ public function order_belongs_to_user(): void { $user = User::factory()->create(); $order = Order::factory()->for($user)->create(); $this->assertInstanceOf(User::class, $order->user); $this->assertEquals($user->id, $order->user->id); } /** @test */ public function orders_scope_filters_by_status(): void { Order::factory()->count(3)->completed()->create(); Order::factory()->count(2)->pending()->create(); // Test the local scope directly $completed = Order::completed()->get(); $pending = Order::pending()->get(); $this->assertCount(3, $completed); $this->assertCount(2, $pending); $this->assertTrue($completed->every(fn($o) => $o->status === 'completed')); } /** @test */ public function eager_loading_prevents_n_plus_one_queries(): void { $order = Order::factory()->create(); OrderItem::factory()->count(5)->for($order)->create(); // Reset the query log DB::enableQueryLog(); // Without eager loading: 1 query for orders + 5 queries for items = 6 queries // With eager loading: 1 query for orders + 1 query for items = 2 queries $orders = Order::with('items')->get(); // Trigger relationship access to populate the query log $orders->each(fn($o) => $o->items->count()); $queryLog = DB::getQueryLog(); // Assert no more than 2 queries (orders + items) $this->assertLessThanOrEqual(2, count($queryLog), sprintf('Expected 2 queries but got %d — N+1 detected', count($queryLog)) ); DB::disableQueryLog(); } }
.... 4 / 4 (100%)
Time: 00:01.847, Memory: 28.00 MB
OK (4 tests, 7 assertions)
- Test relationships directly through the model, not through HTTP. Faster and more precise.
- Test scopes by creating included and excluded records, then asserting the result set.
- Use DB::getQueryLog() to detect N+1 queries in tests.
- Always test with ->with() eager loading to verify the fix works.
- Global scopes (like soft deletes) can silently exclude records. Test explicitly.
| Aspect | RefreshDatabase | DatabaseTransactions |
|---|---|---|
| How it works | Drops all tables and re-runs migrations before each suite run | Wraps each test in a transaction that rolls back after the test |
| Speed | Slow — 2–5s overhead per suite run, not per test | Fast — microsecond rollback per test, no schema rebuild |
| When to use | Testing migrations themselves, or when schema changes between tests | All standard CRUD and business logic tests — the default choice |
| Gotcha | Slow in CI with many test classes due to repeated migration runs | Doesn't roll back DDL statements or changes made by external processes/queue workers |
| Parallel testing safe? | Yes — each worker gets its own database via ParallelTesting::setUpProcess | Risky — parallel workers share the same connection pool, transactions can interfere |
| Seeder support | Full seeders run cleanly after fresh migration | Must re-seed manually or in setUp() since rollback wipes seeded data |
| Best practice | Use for 5-10% of test classes that need fresh schema | Use for 90-95% of test classes as the default isolation strategy |
| CI impact | Each test class triggers a full migration run. 40 classes = 120s of migrations | No migration overhead. Transaction rollback is near-instantaneous |
🎯 Key Takeaways
- Pure unit tests must extend PHPUnit\Framework\TestCase directly — extending Laravel's TestCase boots the full framework on every test and silently inflates your suite runtime by 10–50x for logic-only tests.
- DatabaseTransactions is almost always faster than RefreshDatabase for feature tests — use RefreshDatabase only when you're explicitly testing migration correctness or need a fully fresh schema each run.
- Always call Bus::fake(), Mail::fake(), and Http::fake() before executing the action under test — calling them after is a silent no-op because the real dispatcher already handled the dispatches.
- A test that only asserts the HTTP status code is a false safety net — always assert the response body shape AND the resulting database state to prove the feature actually works end-to-end.
- Bus::fake() intercepts chained and batched dispatches that Queue::fake() silently misses. Use Bus::fake() for all modern Laravel queue testing.
- Test suite performance is a developer experience problem. The three highest-impact fixes: real unit tests, DatabaseTransactions, and targeted factories. Parallel testing is the last optimization.
- Test Eloquent relationships directly through models using DB::getQueryLog() to detect N+1 queries. Relationship tests are fast and catch the most common production performance bug.
- Always test the negative path for side-effects. Add Bus::assertNothingDispatched() and Mail::assertNothingQueued() to failure-path tests.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat's the difference between Bus::fake() and Queue::fake() in Laravel, and when would you choose one over the other?
- QIf your test suite takes 8 minutes to run locally and developers are skipping tests before committing, what specific changes would you make to bring that under 90 seconds?
- QYou have a feature test that passes in isolation but fails when run with the full suite. Walk me through exactly how you'd diagnose and fix test pollution in Laravel PHPUnit.
- QHow do you detect and prevent N+1 queries using tests? What assertion pattern do you use?
- QExplain the difference between RefreshDatabase and DatabaseTransactions. When would you use each, and what are the gotchas with parallel testing?
- QA test asserts assertOk() but the feature is actually broken — the controller returns 200 with an error payload. How do you write tests that catch this class of bug?
- QHow do you test that an Artisan command modifies the database correctly? What assertions do you use beyond expectsOutput()?
- QWhy should private resources return 404 instead of 403? How do you test this in a Laravel policy test?
- QHow do you test a job that dispatches child jobs internally? What happens if you use Queue::fake() instead of Bus::fake()?
- QYou inherit a test suite where 500 'unit' tests extend Laravel's TestCase and each takes 150ms. What is your systematic plan to optimize this?
Frequently Asked Questions
How do I test a Laravel job that dispatches other jobs inside it?
Use Bus::fake() to prevent the outer job from actually running, then assert it was dispatched with the correct payload. To test the job's internal logic including its child dispatches, create the job instance directly and call ->handle() with mocked dependencies — Bus::fake() will still intercept any inner dispatches, letting you use Bus::assertDispatched() for child jobs too.
Should I use Mockery or Laravel's built-in fakes for testing?
Use Laravel's built-in fakes (Mail::fake, Bus::fake, Http::fake) for framework-level side-effects — they're purpose-built and have the richest assertion API. Use Mockery (or PHPUnit's createMock) for your own application service classes and repositories when you want to control return values and assert specific method calls. Mixing both is normal and correct.
Why do my tests pass locally but fail in CI?
The three most common causes are: (1) test pollution — a test earlier in the suite modifies shared state (static properties, config values, fake timers) that bleeds into yours; run with --shuffle to expose ordering dependencies. (2) Timezone differences — CI servers often run UTC while dev machines don't; always use Carbon::setTestNow() and explicit UTC comparisons in date-sensitive tests. (3) Missing environment variables — CI doesn't have your .env.testing; make sure your CI pipeline sets APP_KEY and any required service keys explicitly.
How do I test Laravel Livewire components?
Livewire provides a testing API via Livewire::test(ComponentClass). This boots the component in a test context, lets you call methods with ->call('methodName'), set properties with ->set('property', value), and assert rendered output with ->assertSee(). For components that dispatch events, use Event::fake() before calling Livewire::test(). For components that make HTTP calls, use Http::fake(). The key insight: Livewire tests are feature tests — they boot the framework and should use DatabaseTransactions.
How do I test Laravel scheduled tasks (cron jobs)?
Use $this->artisan('schedule:run') to trigger the scheduler in a test. Combine with RefreshDatabase to ensure a clean state. Assert both the command output and the resulting database state. For commands that should run at specific intervals, test the command logic directly with $this->artisan('your:command') and assert the outcome. Do not test the cron expression itself — that is framework responsibility.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.