Laravel Testing with Pest — Part 1 · Part 2 · Part 3 · Part 4 · Part 5
An order confirmation can send an email, dispatch a PDF job, publish an event, call an accounting API, and store an invoice. A feature test that executes all of that for real is slow, costly, and sometimes unsafe. But a test that fakes every dependency before it exercises the application is also misleading: it proves only that Laravel accepted an assertion.
Laravel fakes sit between those extremes. They replace a transport at a defined boundary and record the intent that crossed it. Mail::fake() does not deliver mail, but it can show which mailable was queued and for whom. Http::fake() does not call a vendor, but it can show the URL, payload, and response handling. The boundary matters: fake the external side effect, never the business decision that caused it.
This article follows one checkout workflow through Mail, Queue, Event, HTTP, Storage, and time. The examples use Pest, but the rule is broader: run the application logic you own, fake the transport you do not own, and retain a few narrow integration tests that join your own components.
Make the application boundary observable
When an order becomes paid, publish a domain event after the database transaction commits. Listeners can then own independent work: send a receipt, generate a PDF, or synchronize accounting. The action still changes real state; only downstream transports will be replaced in tests.
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
final class OrderPaid
{
use Dispatchable;
use SerializesModels;
public function __construct(public Order $order)
{
}
}
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Events\OrderPaid;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
final class MarkOrderAsPaid
{
public function handle(Order $order): void
{
DB::transaction(function () use ($order): void {
$order->update(['paid_at' => now()]);
OrderPaid::dispatch($order)->afterCommit();
});
}
}
This makes a valuable event fake boundary. A test runs MarkOrderAsPaid, persists the payment decision, and verifies an event carrying the right order. It does not need a queue worker, an SMTP server, or a disk driver merely to establish that payment succeeded.
Fake narrowly and at the right moment
Event::fake() without an allow-list suppresses all events, including framework events that a factory, observer, or test setup may need. Create the model first, then fake only the event under test. That sequencing prevents an observer from silently not creating data on which the test depends.
<?php
use App\Actions\MarkOrderAsPaid;
use App\Events\OrderPaid;
use App\Models\Order;
use Illuminate\Support\Facades\Event;
it('marks an order as paid and publishes the follow-up event', function () {
$order = Order::factory()->create(['paid_at' => null]);
Event::fake([OrderPaid::class]);
app(MarkOrderAsPaid::class)->handle($order);
expect($order->refresh()->paid_at)->not->toBeNull();
Event::assertDispatched(OrderPaid::class, function (OrderPaid $event) use ($order): bool {
return $event->order->is($order);
});
});
The callback matters. A bare assertDispatched(OrderPaid::class) says an event existed; it does not prove it was connected to this order. An event fake also cannot prove that a listener is registered or that a worker eventually processes a job. Those are different boundaries and deserve different tests.
Mail and Queue fakes prove delivery intent
Mailables and jobs are normally queued. In a request-level test, fake both layers and assert recipient plus payload. Do not assert every internal view variable: assert the information that is meaningful at the boundary to the customer or the worker.
<?php
use App\Jobs\GenerateInvoicePdf;
use App\Mail\OrderReceipt;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('queues a receipt and invoice work for a paid order', function () {
$order = Order::factory()->forCustomer()->create();
Mail::fake();
Queue::fake();
$this->post(route('orders.pay', $order))->assertRedirect();
Mail::assertQueued(OrderReceipt::class, function (OrderReceipt $mail) use ($order): bool {
return $mail->hasTo($order->customer->email)
&& $mail->order->is($order);
});
Queue::assertPushed(GenerateInvoicePdf::class, function (GenerateInvoicePdf $job) use ($order): bool {
return $job->order->is($order);
});
});
Use Mail::assertQueued() for a mailable implementing ShouldQueue; use assertSent() only when delivery is intentionally synchronous. Queue::fake() proves dispatch, not that GenerateInvoicePdf::handle() works. Give the job a focused test that runs its real code against a fake disk.
<?php
use App\Jobs\GenerateInvoicePdf;
use App\Models\Order;
use Illuminate\Support\Facades\Storage;
it('stores a generated invoice on the private invoice disk', function () {
Storage::fake('invoices');
$order = Order::factory()->paid()->create();
app(GenerateInvoicePdf::class)->handle($order);
Storage::disk('invoices')->assertExists("{$order->id}/invoice.pdf");
Storage::disk('invoices')->assertMissing("{$order->id}/invoice-draft.pdf");
});
The disk is part of the security contract: an invoice must not land on a public disk. This is stronger than asserting that a mock received put() once. Upload tests similarly combine UploadedFile::fake()->create() with Storage::fake() so they never depend on files left by a prior run.
Make HTTP fakes strict
A handwritten mock of an HTTP client commonly duplicates its implementation and never checks the actual request. Laravel's fake records the request after it crosses the framework boundary. Pair it with preventStrayRequests() so an unconfigured URL fails rather than contacting a real service.
<?php
use App\Actions\SyncOrderToAccounting;
use App\Models\Order;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
it('sends an accounting payload with a stable external reference', function () {
Http::preventStrayRequests();
Http::fake([
'https://accounting.example.test/api/invoices' => Http::response(['id' => 'inv_123'], 201),
]);
$order = Order::factory()->paid()->create(['external_reference' => 'ORD-2026-0042']);
app(SyncOrderToAccounting::class)->handle($order);
Http::assertSent(function (Request $request) use ($order): bool {
return $request->url() === 'https://accounting.example.test/api/invoices'
&& $request['reference'] === $order->external_reference
&& $request['total_cents'] === $order->total_cents;
});
});
Add explicit fake responses for a timeout, 422, and 500 whenever those cases lead to different business behaviour. A test returning only 200 cannot prove retry, logging, or a visible failed-sync state. Conversely, do not use an HTTP fake to certify a provider's real schema. Keep a tiny sandbox or contract suite, separately configured and not run on every pull request, for credentials, headers, and vendor behaviour.
Travel through time for business boundaries
Expiry logic is flaky when it asks whether something is valid "now". Fix the clock and test both sides of the boundary. Laravel travel helpers reset the clock at the end of a test; calling travelBack() is still useful when a test returns to real time before it finishes.
<?php
use App\Models\DownloadLink;
use Illuminate\Support\Carbon;
it('rejects an expired download link', function () {
$this->travelTo(Carbon::parse('2026-09-06 10:00:00'));
$link = DownloadLink::factory()->create(['expires_at' => now()->addMinutes(15)]);
$this->travel(16)->minutes();
$this->get(route('downloads.show', $link))->assertGone();
$this->travelBack();
});
Freeze time for expiry, scheduled work, and date-sensitive policy—not for cosmetic timestamps. If a result does not depend on time, asserting an exact created_at couples the test to an irrelevant detail. If setting Carbon::setTestNow() manually, always clear it; leaked time creates failures that depend on suite order.
The integration counterexample
It is easy to create two passing unit-level tests and miss a broken wire. A controller test with Event::fake() proves OrderPaid was emitted. A job test proves the job writes a file. Neither proves that the OrderPaid listener dispatches the invoice job. Retain one narrow integration test that allows the event and listener to run but fakes the final queue transport.
<?php
use App\Actions\MarkOrderAsPaid;
use App\Jobs\GenerateInvoicePdf;
use App\Models\Order;
use Illuminate\Support\Facades\Queue;
it('connects payment, the order-paid listener, and invoice dispatch', function () {
Queue::fake();
$order = Order::factory()->create(['paid_at' => null]);
app(MarkOrderAsPaid::class)->handle($order);
Queue::assertPushed(GenerateInvoicePdf::class, function (GenerateInvoicePdf $job) use ($order): bool {
return $job->order->is($order);
});
});
This catches missing listener registration and accidental payload changes while staying fast. One or two tests of this shape are usually enough. Turning every feature test into a full integration test makes failures slow and difficult to locate.
When not to fake, and what commonly goes wrong
Do not fake pure calculation, validation, or authorization: call the real service and assert its return value or HTTP response. Do not use fakes instead of browser coverage when JavaScript upload behaviour matters, or instead of a controlled provider sandbox check. The useful pyramid is many fast tests with faked transports, a few integration tests joining your own components, and a deliberately small external suite.
The common traps are faking too early, faking the class under test, and asserting only counts. Create factory data before faking observer-dependent events. If SyncOrderToAccounting is the adapter, fake HTTP beneath it—not the adapter itself—so its URL, payload, retry policy, and response handling remain real. Finally, use assertion callbacks: assertPushed(Job::class) says a job existed, not that it belongs to the current order.
Fakes make tests fast by removing uncertainty outside the application. They should not remove all uncertainty. Keep business logic real, make external intent explicit, and preserve enough integration coverage to know that your boundaries still meet.