Laravel Testing with PHPUnit: Advanced Techniques, Mocks & Production Pitfalls
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. Tests aren't bureaucracy; they're the only way to refactor confidently, onboard new engineers safely, and deploy on a Friday without dread.
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. That gap between 'tests exist' and 'tests protect you' is exactly what this article closes.
By the end of this article you'll know how to structure a real-world test suite with proper isolation strategies, write feature tests that exercise the full HTTP stack without flakiness, mock Eloquent relationships and external services cleanly, fake queues and events to assert side-effects without actually running workers, and diagnose the performance and reliability problems that silently rot test suites over time. This isn't a beginner's tour — it's the playbook for teams who want their CI pipeline to mean something.
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)
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 { // Arrange: create a user with an active subscription using factory states // The ->withActiveSubscription() state sets all the right timestamps and status fields $subscriber = User::factory() ->withActiveSubscription($this->premiumPlan) ->create(); // Act as that user via Sanctum — no real token generation needed in tests Sanctum::actingAs($subscriber); // Act: hit the premium content endpoint $response = $this->getJson('/api/content/premium-report'); // Assert: full response shape, not just status code $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 { // The suspended() factory state sets status='suspended' and suspended_at=now() // This is cleaner than inlining raw attributes in every test $suspendedUser = User::factory() ->withActiveSubscription($this->premiumPlan) ->suspended() ->create(); Sanctum::actingAs($suspendedUser); $response = $this->getJson('/api/content/premium-report'); // 402 Payment Required is the semantically correct status for subscription problems $response ->assertStatus(402) ->assertJsonPath('error.code', 'SUBSCRIPTION_SUSPENDED') ->assertJsonMissing(['data']); // Never leak data in error responses } /** @test */ public function subscription_expiry_date_is_stored_as_utc(): void { // Edge case: ensure timezone handling doesn't corrupt DB-stored datetimes $user = User::factory()->create(); $expiresAt = now('America/New_York')->addMonth(); // User in NYC timezone $subscription = Subscription::factory()->create([ 'user_id' => $user->id, 'plan_id' => $this->premiumPlan->id, 'expires_at' => $expiresAt, ]); // Reload from DB — this catches timezone drift during persist/retrieve cycles $subscription->refresh(); // The stored value must be UTC regardless of the input timezone $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)
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 app()->make() 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'.
<?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() replaces the queue dispatcher binding in the container right now. Bus::fake(); Mail::fake(); // Fake the external payment gateway — never hit Stripe in tests 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(); // 201 // Assert both jobs were dispatched — don't assert they ran (Bus::fake() prevents that) Bus::assertDispatched(ProcessPayment::class, function (ProcessPayment $job) use ($buyer) { // Inspect job properties to ensure correct data was passed 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; }); // Assert jobs were dispatched in the correct order — chained jobs matter in payments Bus::assertDispatchedTimes(ProcessPayment::class, 1); Bus::assertDispatchedTimes(ReserveInventory::class, 1); // Assert the confirmation email was queued (not sent synchronously) Mail::assertQueued(OrderConfirmation::class, function (OrderConfirmation $mail) use ($buyer) { return $mail->hasTo($buyer->email); }); // Assert the Stripe HTTP call was made with the right payload 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(); // Simulate a declined card — test the unhappy path explicitly 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() // 422 — validation-style failure ->assertJsonPath('error.code', 'CARD_DECLINED'); // Nothing should be dispatched if payment failed — inventory must not be reserved Bus::assertNothingDispatched(); Mail::assertNothingQueued(); } }
.. 2 / 2 (100%)
Time: 00:00.847, Memory: 28.00 MB
OK (2 tests, 12 assertions)
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(); // actingAs sets the currently authenticated user for Gate checks $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); // Editors should override ownership — test this explicitly $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)); // Also test via HTTP to confirm the controller respects the policy $response = $this->patchJson("/api/articles/{$article->id}", [ 'title' => 'Hijacked Title', ]); // Must be 403 Forbidden, NOT 404 — leaking resource existence is a security issue $response->assertForbidden(); } /** @test */ public function it_does_not_leak_article_existence_to_unauthorized_users(): void { $owner = User::factory()->create(); // Note: draft articles should be invisible to non-owners $draftArticle = Article::factory()->for($owner, 'author')->draft()->create(); $visitor = User::factory()->create(); $this->actingAs($visitor); // GET request from a non-owner should 404, not 403 — don't confirm the resource exists $this->getJson("/api/articles/{$draftArticle->id}")->assertNotFound(); } } // ============================================================ // ARTISAN COMMAND TEST — in a separate file normally, // combined here for brevity // ============================================================ 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 { // Create 3 subscriptions: 2 expired, 1 still active $expiredOne = Subscription::factory()->expiredYesterday()->create(); $expiredTwo = Subscription::factory()->expiredLastWeek()->create(); $activeOne = Subscription::factory()->expiresNextMonth()->create(); // artisan() returns a PendingCommand — chain assertions fluently $this->artisan('subscriptions:expire') ->expectsOutput('Processing expired subscriptions...') ->expectsOutput('2 subscription(s) marked as expired.') ->assertExitCode(0); // 0 = success, anything else = failure // Verify the database state, not just the output string $this->assertDatabaseHas('subscriptions', [ 'id' => $expiredOne->id, 'status' => 'expired', ]); $this->assertDatabaseHas('subscriptions', [ 'id' => $expiredTwo->id, 'status' => 'expired', ]); // The active subscription must NOT be touched $this->assertDatabaseHas('subscriptions', [ 'id' => $activeOne->id, 'status' => 'active', // unchanged ]); } }
..... 5 / 5 (100%)
Time: 00:01.203, Memory: 30.00 MB
OK (5 tests, 11 assertions)
| 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 |
🎯 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Calling Bus::fake() or Mail::fake() AFTER the action under test — Symptom: Bus::assertDispatched() always fails even when you can see the job was dispatched in logs — Fix: Move Bus::fake() to the very first line of your test method, or into setUp() if the whole class needs it. The fake replaces the container binding at call time; if your code already dispatched before the fake was registered, the real dispatcher handled it and the spy recorded nothing.
- ✕Mistake 2: Using $this->seed() inside every test method — Symptom: Test suite takes 4+ minutes in CI because full seeders run hundreds of times — Fix: Only seed what each test actually needs using Model factories with specific states. Reserve $this->seed(DatabaseSeeder::class) for smoke tests or integration tests that need a fully realistic dataset. For everything else, factory()->create() with explicit attributes is faster and more readable.
- ✕Mistake 3: Writing assertions only on HTTP status codes, ignoring response body and database state — Symptom: A test passes with assertOk() but the feature is actually broken because the controller returns 200 with an error payload, or saves corrupted data silently — Fix: Always assert at least three things: the status code, a key field in the JSON response body using assertJsonPath(), and the resulting database state using assertDatabaseHas(). A green test that only checks the status code is barely better than no test at all.
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.
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.