Skip to content
Home PHP Laravel Testing with PHPUnit: Advanced Techniques, Mocks & Production Pitfalls

Laravel Testing with PHPUnit: Advanced Techniques, Mocks & Production Pitfalls

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Laravel → Topic 13 of 15
Laravel PHPUnit testing deep-dive: feature vs unit tests, mocking Eloquent, faking queues, database strategies, and production gotchas senior devs actually hit.
🔥 Advanced — solid PHP foundation required
In this tutorial, you'll learn
Laravel PHPUnit testing deep-dive: feature vs unit tests, mocking Eloquent, faking queues, database strategies, and production gotchas senior devs actually hit.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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.
🚨 START HERE
Laravel PHPUnit Triage Commands
Rapid commands to isolate test suite problems.
🟠Test suite too slow (8+ minutes).
Immediate ActionProfile to find the slow tests.
Commands
php artisan test --profile --order-by=defects
php artisan test --parallel --processes=4
Fix NowIf individual tests are over 100ms, they are likely booting the framework unnecessarily. Move pure logic tests to extend PHPUnit\Framework\TestCase. Add DatabaseTransactions instead of RefreshDatabase where possible.
🟡Tests pass locally but fail in CI.
Immediate ActionCheck for environment differences and test pollution.
Commands
php artisan test --shuffle --reorder=random
php artisan test --filter=FailingTest --verbose
Fix NowIf shuffle changes which tests fail, you have test pollution. Check for static state, config overrides, Carbon::setTestNow(), and unflushed fakes. Also verify .env.testing exists in CI and APP_KEY is set.
🟡Bus::assertDispatched() fails but jobs appear in logs.
Immediate ActionVerify Bus::fake() is called before the action.
Commands
grep -n 'Bus::fake' tests/Feature/YourTest.php
php artisan test --filter=YourTest --verbose
Fix NowMove Bus::fake() to the first line of the test or into setUp(). If the test class needs fakes for every test, add them to setUp() and flush in tearDown().
🟡Database constraint violations in parallel tests.
Immediate ActionCheck if parallel workers share the same database.
Commands
php artisan test --parallel --processes=4 --verbose
cat tests/TestCase.php | grep -i 'database'
Fix NowAdd ParallelTesting::setUpProcess() to create per-worker databases. Or use SQLite in-memory with :memory: for each worker. Never let parallel workers share a MySQL database with DatabaseTransactions.
🟡assertDatabaseHas() passes but the feature is actually broken.
Immediate ActionCheck if the test asserts response body and not just database state.
Commands
php artisan test --filter=YourTest --verbose
php artisan tinker --execute="App\Models\YourModel::latest()->first()"
Fix NowAdd assertJsonPath() to verify the response shape. A test that only checks database state might miss controller-level bugs like missing serialization or wrong HTTP status codes.
Production IncidentPayment Emails Sent to Real Customers from CI PipelineA SaaS company's CI pipeline ran feature tests against a staging database. Three tests that placed orders never called Mail::fake() or Bus::fake(). Real payment confirmation emails were sent to 47 real customers over a 2-week period. The company received 12 chargebacks and lost $3,800 in Stripe fees before anyone noticed.
SymptomCustomer support received complaints about duplicate order confirmation emails. Some customers received emails for orders they never placed. Stripe dashboard showed 12 chargebacks from customers confused by phantom order confirmations. The team initially suspected a webhook replay bug.
AssumptionA Stripe webhook handler was processing events twice, causing duplicate emails.
Root causeThree feature tests in the order placement test class created real User models via factories but used email addresses from production seed data that was accidentally committed to the repository. The tests called $this->actingAs($user)->postJson('/api/orders', ...) without calling Mail::fake() or Bus::fake() first. The real Mailable was dispatched, hit the real SMTP driver (configured via CI environment variables that pointed to production SES), and sent real emails. The tests passed because they only asserted HTTP status codes — they never checked whether emails were sent or not.
Fix1. Added Mail::fake() and Bus::fake() to the setUp() method of the base TestCase class so all feature tests fake side-effects by default. 2. Replaced all production email addresses in test factories with @example.test domains. 3. Added a PHPUnit listener that fails any test if Mail::fake() or Bus::fake() was not called before an HTTP action. 4. Configured CI to use a local Mailhog instance instead of production SES. 5. Added assertNothingSent() assertions to all negative test cases.
Key Lesson
Always call Mail::fake() and Bus::fake() before the action under test — not after. The fake replaces the container binding at call time.Never use real email addresses in test factories. Use @example.test domains exclusively.Tests that only assert HTTP status codes are false safety nets. Assert side-effects explicitly.CI environment variables must never point to production services. Use local fakes (Mailhog, LocalStack) for all external dependencies.Add Mail::assertNothingSent() to negative test cases — if a failure path sends an email, something is catastrophically wrong.
Production Debug GuideSymptom-first investigation path for test suite problems.
Tests pass individually but fail when run with the full suite.Test pollution. Something earlier in the suite modified shared state. Run with --shuffle to expose ordering dependencies. Check for: static properties, config overrides not restored in tearDown(), Carbon::setTestNow() not reset, fakes not flushed between tests.
Bus::assertDispatched() always fails even though logs show jobs were dispatched.Bus::fake() was called after the action under test. Move it to setUp() or the first line of the test. The fake replaces the container binding at call time — if your code dispatched before the fake was registered, the spy recorded nothing.
Tests pass locally but fail in CI with database constraint violations.Parallel test workers are sharing the same database. Transactions from worker A are visible to worker B. Use RefreshDatabase with ParallelTesting::setUpProcess to give each worker its own database. Or use SQLite in-memory per worker.
Test suite takes 8+ minutes locally.Too many tests extending Laravel's TestCase when they should extend PHPUnit directly. Run php artisan test --profile to find slow tests. Anything over 50ms in a 'unit' test is booting the framework unnecessarily. Also check if $this->seed() is called in every test method instead of using targeted factories.
assertDatabaseHas() fails but the data exists when you manually query.The test transaction has not committed. If your code under test opens a separate DB connection (queue workers, artisan commands), the data exists on a different connection. Use RefreshDatabase instead of DatabaseTransactions, or restructure to avoid cross-connection writes.
Carbon::now() returns different values in tests vs production behavior.Someone called Carbon::setTestNow() in an earlier test and never reset it. Always call Carbon::setTestNow(null) in tearDown(). Better: use the InteractsWithTime trait which handles cleanup automatically.

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.

tests/Unit/OrderCalculatorTest.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
<?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
        );
    }
}
▶ Output
PHPUnit 10.5 by Sebastian Bergmann and contributors.

.. 2 / 2 (100%)

Time: 00:00.008, Memory: 8.00 MB

OK (2 tests, 4 assertions)
⚠ Watch Out: The Hidden Bootstrap Tax
If your 'unit' tests extend Laravel's TestCase, run 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.
📊 Production Insight
A team had 800 'unit' tests averaging 150ms each. Total suite time: 2 minutes. After auditing, 600 of those tests tested pure domain logic that never touched Laravel. Moving them to extend PHPUnit\Framework\TestCase dropped per-test time to 0.3ms. Suite time dropped from 2 minutes to 32 seconds. The remaining 200 feature tests stayed on TestCase with DatabaseTransactions. The CI pipeline went from 8 minutes (with parallelism overhead) to under 90 seconds.
🎯 Key Takeaway
The test base class determines overhead, not what the test asserts. Pure logic tests extending Laravel's TestCase pay a 50-200ms bootstrap tax per test for zero benefit. Audit your suite with --profile and move pure logic tests to PHPUnit\Framework\TestCase.

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()].

tests/Feature/SubscriptionFeatureTest.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
<?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()
        );
    }
}
▶ Output
PHPUnit 10.5 by Sebastian Bergmann and contributors.

... 3 / 3 (100%)

Time: 00:02.341, Memory: 32.00 MB

OK (3 tests, 9 assertions)
💡Pro Tip: assertJsonPath with Closures
  • 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.
📊 Production Insight
A team used RefreshDatabase for all 400 feature tests. Each test class triggered a full migration run. With 40 test classes, the suite spent 120 seconds just running migrations. Switching 35 test classes to DatabaseTransactions (they only needed clean data, not a fresh schema) dropped migration time to 6 seconds (5 classes still used RefreshDatabase for migration testing). Total suite time dropped from 8 minutes to under 2 minutes. The remaining 5 classes using RefreshDatabase were moved to a separate test group that ran only on schema-change PRs.
🎯 Key Takeaway
DatabaseTransactions is the correct default for 90% of feature tests. Reserve RefreshDatabase for migration testing and schema-dependent edge cases. If your suite spends more than 10% of its time on migrations, you are using the wrong trait.

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'.

tests/Feature/OrderPlacementTest.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
<?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();
    }
}
▶ Output
PHPUnit 10.5 by Sebastian Bergmann and contributors.

.. 2 / 2 (100%)

Time: 00:00.847, Memory: 28.00 MB

OK (2 tests, 12 assertions)
🔥Interview Gold: Bus::fake() vs Queue::fake()
  • 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.
📊 Production Insight
A team tested their order pipeline with Queue::fake(). The tests passed — the top-level ProcessOrderJob was intercepted. But ProcessOrderJob dispatched a child SendWebhookJob internally. Since Queue::fake() only intercepts the top-level push, the child job hit the real Redis queue and executed against production webhook endpoints during CI runs. Three production partners received malformed webhook payloads. Switching to Bus::fake() intercepted both the parent and child dispatches, catching the issue in the test suite instead of production.
🎯 Key Takeaway
Always use Bus::fake() over Queue::fake() in modern Laravel. Bus::fake() intercepts chained and batched dispatches that Queue::fake() silently misses. Call all fakes before the action under test — calling them after is a silent no-op.

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.

tests/Feature/ArticlePolicyAndCommandTest.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
<?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',
        ]);
    }
}
▶ Output
PHPUnit 10.5 by Sebastian Bergmann and contributors.

..... 5 / 5 (100%)

Time: 00:01.203, Memory: 30.00 MB

OK (5 tests, 11 assertions)
⚠ Watch Out: 403 vs 404 — A Real Security Boundary
Returning 403 Forbidden for a resource the user doesn't own confirms the resource exists. Attackers enumerate IDs this way. For private resources like draft posts or private messages, return 404 to deny existence entirely. Test this explicitly — your policy should scope the query, not just check ownership after fetching.
📊 Production Insight
A team tested their ArticlePolicy with Gate::allows() but never tested the HTTP response. The policy correctly denied access, but the controller fetched the article model before checking the policy. The 403 response included the article title in the error message for debugging purposes. Penetration testers found they could enumerate all article titles by requesting random IDs — each 403 response leaked the title. The fix was twofold: scope the query to only return articles the user can access, and remove the article title from 403 error responses.
🎯 Key Takeaway
Test policies through both Gate and HTTP. Gate::allows() tests the authorization logic; HTTP tests verify the controller respects the policy and does not leak data. Always return 404 for private resources, never 403.

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.

tests/TestCase.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839
<?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();
    }
}
▶ Output
All feature tests now fake side-effects by default. Tests that need real dispatches call $this->withoutFakes(). Suite runtime: unchanged. Test reliability: dramatically improved.
Mental Model
The 3-Second Rule for Test Suite Performance
php artisan test --filter=YourClass runs only that class. If this takes over 3 seconds, your class is either testing too many things or paying unnecessary bootstrap costs.
  • 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.
📊 Production Insight
A team's suite took 11 minutes with 1,200 tests. Audit revealed: 700 'unit' tests extending TestCase (should be PHPUnit), 35 test classes using RefreshDatabase (should be DatabaseTransactions), and every test calling $this->seed() (should use targeted factories). After fixes: 700 unit tests moved to PHPUnit (dropped from 105s to 0.2s), 35 classes switched to DatabaseTransactions (dropped from 140s to 8s), seeders replaced with factories (dropped from 90s to 12s). Parallel testing with 4 workers brought the remaining feature tests from 180s to 52s. Total suite time: 11 minutes to 68 seconds.
🎯 Key Takeaway
Test suite performance is a developer experience problem, not a CI problem. The three highest-impact fixes: real unit tests (not fake ones), DatabaseTransactions (not RefreshDatabase), and targeted factories (not full seeders). Parallel testing is the last optimization, not the first.

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.

tests/Unit/OrderRelationshipTest.php · PHP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
<?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();
    }
}
▶ Output
PHPUnit 10.5 by Sebastian Bergmann and contributors.

.... 4 / 4 (100%)

Time: 00:01.847, Memory: 28.00 MB

OK (4 tests, 7 assertions)
💡Pro Tip: Catching N+1 Queries in Tests
  • 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.
📊 Production Insight
A dashboard endpoint loaded 500 orders and displayed each order's items, customer, and payment. Without eager loading, this triggered 1,501 queries (1 for orders + 500 for items + 500 for customer + 500 for payment). Response time: 4.2 seconds. Adding ->with(['items', 'customer', 'payment']) dropped it to 4 queries and 85ms. The test that would have caught this: assert that loading 500 orders with relationships executes no more than 4 queries using DB::getQueryLog().
🎯 Key Takeaway
Test Eloquent relationships directly through the model. Use DB::getQueryLog() to detect N+1 queries. Test scopes by asserting the filtered result set, not just that the scope method exists. Relationship tests are fast (unit test speed) and catch the most common production performance bug.
🗂 Database Testing Strategies Compared
Choosing the right database isolation strategy for your test suite.
AspectRefreshDatabaseDatabaseTransactions
How it worksDrops all tables and re-runs migrations before each suite runWraps each test in a transaction that rolls back after the test
SpeedSlow — 2–5s overhead per suite run, not per testFast — microsecond rollback per test, no schema rebuild
When to useTesting migrations themselves, or when schema changes between testsAll standard CRUD and business logic tests — the default choice
GotchaSlow in CI with many test classes due to repeated migration runsDoesn'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::setUpProcessRisky — parallel workers share the same connection pool, transactions can interfere
Seeder supportFull seeders run cleanly after fresh migrationMust re-seed manually or in setUp() since rollback wipes seeded data
Best practiceUse for 5-10% of test classes that need fresh schemaUse for 90-95% of test classes as the default isolation strategy
CI impactEach test class triggers a full migration run. 40 classes = 120s of migrationsNo 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

    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.

    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.

    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.

    Using Queue::fake() instead of Bus::fake() for chained jobs
    Symptom

    Tests pass but child jobs dispatched from within a parent job hit the real queue in CI —

    Fix

    Queue::fake() only intercepts top-level queue pushes. If your jobs dispatch child jobs internally, those child dispatches bypass Queue::fake() entirely. Use Bus::fake() which intercepts all dispatches including chained and batched jobs.

    Not resetting Carbon::setTestNow() in tearDown()
    Symptom

    Tests pass individually but fail when run with the full suite, with date-dependent assertions producing wrong results —

    Fix

    Always call Carbon::setTestNow(null) in tearDown(). Better: use the InteractsWithTime trait which handles cleanup automatically. A forgotten setTestNow() in test 5 bleeds into test 6 and corrupts all date assertions.

    Testing middleware indirectly through 20 layers of feature tests
    Symptom

    Middleware bugs are hard to diagnose because the failure manifests as a wrong HTTP response from a complex endpoint, not from the middleware itself —

    Fix

    Register a test-only route in setUp() that applies only the middleware under test. Hit that route with controlled request state and assert the exact HTTP outcome. This isolates the middleware from controller and service layer complexity.

    Using real email addresses in test factories
    Symptom

    Real emails sent to real addresses during CI runs, causing customer confusion and potential legal liability —

    Fix

    Use @example.test domains exclusively in test factories. Configure CI to use a local Mailhog instance instead of production SMTP. Add Mail::fake() to setUp() as a safety net.

    Not testing the negative path for side-effects
    Symptom

    A test passes when the happy path works, but the error path still dispatches jobs or sends emails —

    Fix

    Add Bus::assertNothingDispatched() and Mail::assertNothingQueued() to all failure-path tests. If a payment decline still reserves inventory, your test should catch it.

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.

🔥
Naren Founder & Author

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.

← PreviousLaravel Events and ListenersNext →Laravel Sanctum API Authentication
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged