Building Web Apps with Laravel Module 12 – Testing Laravel applications
Fakes, mocks and time travel
Test a Laravel 13 fee reminder that texts guardians at 09:00 IST: facade fakes, Mockery mocks and spies, a moved clock, and a test that failed at night.
What you will learn
- Replace notifications, HTTP calls and jobs in a test with Laravel's fakes and check what they recorded
- Write a reminder test that sets the clock with travelTo() and gives the same result at any hour
- Test a contract you own with a Mockery mock, a spy or a partial mock, and read the failures they report
- Diagnose a test that fails only at some times of day and make it deterministic
Before you start
What you’ll learn · Before you start
What you will learn
- Replace notifications, HTTP calls and jobs in a test with Laravel's fakes and check what they recorded
- Write a reminder test that sets the clock with travelTo() and gives the same result at any hour
- Test a contract you own with a Mockery mock, a spy or a partial mock, and read the failures they report
- Diagnose a test that fails only at some times of day and make it deterministic
Before you start
On this page
Pathshala’s fees:remind command runs at 09:00 IST and texts one SMS to the guardian of each pending fee instalment
due within three days. Its test cannot wait for 09:00, must never text a real phone, and has to give the same answer
at 15:40 on a laptop and at 02:00 in a nightly build.
Three tools make that possible. Laravel’s fakes record what the code would have sent, mocks fail the test when the code
calls them wrongly, and time travel sets the clock that now() reads. The code is the Pathshala fees app of
HTTP tests and database tests, and every run below was recorded on the
course’s Laravel 13.34.0 project with Mockery 1.6.15.
Fakes, mocks and time travel each replace one outside thing
Follow one reminder out of the app. The command reads the clock to learn what today is, and the notification goes to an SMS channel, which hands its text to a provider. The payment code beside it asks ExamplePay’s API about money and queues jobs for receipts. In a test, each of these is taken over by a test double: Any object or setting a test puts in place of a real collaborator for one test, so the code can run without it: a fake, a mock, a spy or a fixed clock.:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Four things outside the code. The reminder texts guardians through an SMS provider, a payment is checked with ExamplePay, receipts are made by queue workers, and every date rule reads the clock. A test must touch none of them for real.
- A fake records the SMS. Notification::fake() swaps Laravel's notification sender for a recorder. The reminder is kept in memory, and the test asks afterwards who got which notification. No message leaves the machine.
- A fake answers for ExamplePay. Http::fake() answers the payment lookup with JSON the test wrote, and Http::assertSent() checks the request that went to it. The real API is never called.
- A fake records the jobs. Bus::fake() keeps every dispatched job, chain and batch without running it, so the test checks what was queued, and where, without a worker.
- Time travel sets the clock. travelTo() fixes the moment that now() returns, such as 09:00 IST on the 12th, and travel(1)->day() moves it on. The rule gives the same answer whenever the test runs.
- A mock is the fifth stand-in. When the SMS channel and its text must run too, the test keeps real notifications and puts a mock of the app's own SmsGateway contract in the container instead of the provider.
Fakes are Laravel’s own doubles for its services: a facade fake: A test double installed with a facade's fake() method, such as Mail::fake() or Storage::fake(), which replaces the real service for one test and records what the code did. More in “Service providers, facades and contracts” such as
Notification::fake() swaps the service for a recorder, and the test asks afterwards what was sent and to whom.
Mocks are doubles of your own classes, built with Mockery, which every Laravel project includes. A
mock: A test double that is told before the code runs which calls to expect, with which arguments and how often. A call that breaks those expectations fails the test. is told the expected calls before the code runs, and a call with other arguments, or
one too many, fails the test. time travel: In a Laravel test, setting the moment that now() and Carbon return, with travelTo() for a fixed moment or travel() to move it, so a date rule can be tested at any hour. sets the moment that now() returns, so a
rule about 09:00 IST can be tested at any hour.
| Fakes | Mocks | Time travel | |
|---|---|---|---|
| Replaces | a Laravel service | your class or interface | the clock |
| Made with | Notification::fake() and others |
$this->mock() |
travelTo(), travel() |
| The test states | what happened, afterwards | the calls, beforehand | the moment |
The later sections use each of these:
Bus::fake()records jobs, chains and batches without running them.Http::fake()answers outbound requests, andHttp::preventStrayRequests()fails any request no fake covers.Event::fake()given one event class records only that event.Storage::fake('local')swaps a disk for an empty temporary folder.$this->spy()records every call, for the test to check afterwards.$this->partialMock()replaces chosen methods of a real class and keeps the rest.
Key idea
Use fakes for what Laravel sends out, mocks for a contract of your own, and time travel for every rule that reads the clock.
Checkpoint
Check: What fakes, mocks and time travel replace
One reminder test, with a fake and a moved clock
The first test of FeeReminderTest.php checks the main path: at 09:00 IST on the 12th, an instalment due on the 15th
gets one reminder. Read it beside the command:
reminders-app/tests/Feature/FeeReminderTest.php
<?php
namespace Tests\Feature;
use App\Models\Instalment;
use App\Models\Student;
use App\Notifications\FeeDueReminder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class FeeReminderTest extends TestCase
{
use RefreshDatabase;
public function test_a_guardian_is_reminded_three_days_before_the_due_day(): void
{
Notification::fake();
$this->travelTo(Carbon::parse('2026-01-12 09:00', 'Asia/Kolkata'));
$student = Student::factory()->withGuardian()->create(['name' => 'Riya Sharma']);
$instalment = Instalment::factory()->for($student)->create(['due_on' => '2026-01-15']);
$this->artisan('fees:remind')
->expectsOutput('Reminders sent: 1, failed: 0')
->assertSuccessful();
Notification::assertSentTo(
$student->guardian,
FeeDueReminder::class,
fn (FeeDueReminder $reminder) => $reminder->instalment->is($instalment),
);
Notification::assertCount(1);
$this->assertSame('2026-01-12 03:30:00', $instalment->fresh()->reminded_at->toDateTimeString());
}
public function test_nobody_is_reminded_four_days_before_the_due_day(): void
{
Notification::fake();
$this->travelTo(Carbon::parse('2026-01-11 09:00', 'Asia/Kolkata'));
$student = Student::factory()->withGuardian()->create();
Instalment::factory()->for($student)->create(['due_on' => '2026-01-15']);
$this->artisan('fees:remind')
->expectsOutput('Reminders sent: 0, failed: 0')
->assertSuccessful();
Notification::assertNothingSent();
}
} reminders-app/app/Console/Commands/SendFeeReminders.php
<?php
namespace App\Console\Commands;
use App\Exceptions\SmsFailed;
use App\Models\Instalment;
use App\Notifications\FeeDueReminder;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
// Runs every morning at 09:00 IST (routes/console.php). Each pending instalment due within the next three days gets
// one SMS reminder. A send that fails leaves reminded_at empty, so the next morning's run tries again.
#[Signature('fees:remind')]
#[Description('Send one SMS reminder for each pending instalment due within three days')]
class SendFeeReminders extends Command
{
public function handle(): int
{
// The rule follows the guardian's calendar, so "today" is the day in India, not in the app's UTC.
$today = Carbon::now('Asia/Kolkata')->startOfDay();
$instalments = Instalment::query()
->where('status', 'pending')
->whereNull('reminded_at')
->whereDate('due_on', '>=', $today->toDateString())
->whereDate('due_on', '<=', $today->copy()->addDays(3)->toDateString())
->whereHas('student.guardian')
->with('student.guardian')
->orderBy('id')
->get();
$sent = 0;
$failed = 0;
foreach ($instalments as $instalment) {
try {
$instalment->student->guardian->notify(new FeeDueReminder($instalment));
} catch (SmsFailed $e) {
report($e);
$failed++;
continue;
}
$instalment->forceFill(['reminded_at' => Carbon::now()])->save();
$sent++;
}
$this->info("Reminders sent: {$sent}, failed: {$failed}");
return self::SUCCESS;
}
} reminders-app/app/Notifications/FeeDueReminder.php
<?php
namespace App\Notifications;
use App\Models\Instalment;
use App\Notifications\Channels\SmsChannel;
use Illuminate\Notifications\Notification;
// The SMS a guardian gets before an instalment is due. Amounts are integer paise: 118000 paise is Rs 1,180.00.
class FeeDueReminder extends Notification
{
public function __construct(public Instalment $instalment)
{
}
public function via(object $notifiable): array
{
return [SmsChannel::class];
}
public function toSms(object $notifiable): string
{
$rupees = number_format($this->instalment->amount_paise / 100, 2);
return "Pathshala: the fee instalment of Rs {$rupees} for {$this->instalment->student->name}"
." is due on {$this->instalment->due_on->toDateString()}.";
}
} Step through it in order:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The fake goes in first. Notification::fake() replaces the notification sender before anything else runs, so every notification of this test is recorded and none is sent.
- The clock moves to 09:00 IST. travelTo() sets the clock to 09:00 in India on the 12th. From now on, now() returns that moment in every class the test runs.
- Factories write the rows. A student with a guardian and one pending instalment of 118000 paise, due on the 15th, three days ahead.
- The test runs the command. artisan('fees:remind') runs the command in the test's own process. It takes today in India and finds the instalment, due within three days and never reminded.
- The reminder is recorded. The guardian's notify() call reaches the fake instead of the SMS channel. The command marks reminded_at and prints Reminders sent 1, failed 0.
- The test asks the fake. assertSentTo() checks that this guardian got a FeeDueReminder for this instalment, and assertCount(1) that nothing else went out. The two tests made 8 assertions.
from session import run
# The fee reminder of the free part (reminders-app/tests/Feature/FeeReminderTest.php): Notification::fake() records
# the SMS notification instead of sending it, and travelTo() sets the clock to 09:00 IST on the 12th (the reminder
# is due) and on the 11th (too early). Nothing leaves the machine and the result does not depend on today's date.
run(
"""
export COLLISION_IGNORE_DURATION=true
php artisan test tests/Feature/FeeReminderTest.php
""",
app=True,
overlay=("reminders-app",),
) Output
$ export COLLISION_IGNORE_DURATION=true $ php artisan test tests/Feature/FeeReminderTest.php PASS Tests\Feature\FeeReminderTest ✓ a guardian is reminded three days before the due day ✓ nobody is reminded four days before the due day Tests: 2 passed (8 assertions) Duration: …s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reminder_fake_session.py
Both tests passed, with 8 assertions. The fake comes first, because it records only what happens after it is in place.
The second test stands at 09:00 IST on the 11th, when the 15th is four days away, and assertNothingSent() proves the
window’s edge. The first test read reminded_at back as 03:30 on the 12th: the app keeps UTC, as config/app.php
sets, and the run shows India’s clock five and a half hours ahead of it.
freezeTime() is not a fixed moment
Wrong: call $this->freezeTime() and build the due day from today(). It gives a
frozen clock: A test clock that does not move: every call to now() returns the same moment until the test sets or moves it again. stopped at the moment the test starts, so the result still depends on the
day it runs. Right: travelTo() the moment the rule is about.
Checkpoint
Check: A reminder test with a fake and a moved clock
Every fake on one payment, and the bug a fake cannot see Pro: opens with a Pro, Premium or Ultimate pass
A payment reaches further out than a reminder: an API, an event, a chain of jobs, a queue of its own and a disk. This part tests all of it with Laravel’s fakes, then breaks a job to show what a faked test misses, and stops a request that no fake answers.
In this part:
- Three fakes around one payment
- Chains, queues and batches, asserted without a worker
- A green test over a broken job
- A request no fake answers
1 diagram · 2 runnable examples (PaymentTest.php, Python, RecordPayment.php, SendStatements.php, StatementsTest.php) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Mocks, spies and partial mocks on a contract you own, and their failures Pro: opens with a Pro, Premium or Ultimate pass
A notification fake hides the SMS channel and the text. This part keeps real notifications, replaces only the app’s
SmsGateway contract with Mockery doubles, reads the two failures a mock reports, and tests a refused message.
In this part:
- A mock is told the call before the command runs
- The two failures a mock reports
- A spy asks afterwards, a partial mock keeps the real code
- A refused message, from andThrow()
2 runnable examples (HttpSmsGateway.php, Python, ReminderSmsTest.php, SmsChannel.php, SmsGateway.php) · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Time travel through the reminder's week, and a test that failed at night Pro: opens with a Pro, Premium or Ultimate pass
A rule about mornings needs tests that stand on several mornings. This part moves one instalment through its week, finds a retry bug that only a second morning shows, and replays a test that failed between midnight and 05:30 IST.
In this part:
- Four cases on a clock the test moves
- The retry that a second morning found
- A test that failed between midnight and 05:30 IST
- An exercise with a rubric: the reminder’s week, end to end
- What to keep from the three parts
1 diagram · 2 runnable examples (Python, ReminderRulesTest.php, The exact-day version, routes/console.php) · about 8 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
Build the two reminder tests in your own Pathshala project, then break each one on purpose: put
Notification::fake() after the command, move travelTo() to the 11th, and swap travelTo() for freezeTime().
Say what each change will do before you run it. The self-check starts where your answers have been weakest:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
A warm-up for freshers and 2–5 years: how does a fake differ from a mock in a Laravel test?
A fake replaces one of Laravel’s services with a recorder. After Notification::fake() or Bus::fake() the code runs as usual, nothing leaves the app, and the test asks afterwards what was recorded. A mock stands in for a class or interface of your own, made with Mockery, and is told before the code runs which calls to expect; a call with other arguments, or too many calls, fails the test. Use a fake when Laravel has one and the question is what was sent, and a mock when the exact call to your own collaborator matters.
The three harder questions below use the same reminder and payment code. Write down your first step for each before you open it:
-
Mid to senior
After a deploy, some guardians got the same fee reminder SMS twice on one morning, yet every reminder test is green. How would you find the cause, and which tests would you add so that it cannot happen again?
-
Fresher to mid
A reminder test fails in the nightly CI run at 02:00 IST and succeeds every time a developer runs it during the day. Nobody changed the code. Explain the most likely cause and how you would make the test deterministic.
-
Mid to senior
Every feature test of a payment flow is green with Bus::fake(), yet in production the job that copies payments into the ledger fails on every payment. How did the suite miss it, and how would you change the tests?
Flashcards · 5 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does each of Laravel's fakes, mocks and time travel replace in a test?
A fake, such as Notification::fake(), swaps a Laravel service for a recorder the test reads afterwards. A mock, built with Mockery, is told the expected calls of your own class and fails on any other. Time travel sets the moment now() returns.
-
Which test doubles does Laravel offer besides Notification::fake()?
Bus::fake() records jobs, chains and batches without running them; Http::fake() answers outbound requests; Event::fake() given one class records only that event; Storage::fake('local') swaps a disk for an empty folder; $this->spy() records every call; $this->partialMock() replaces chosen methods of a real class.
-
Why must Notification::fake() come before the code under test runs?
A fake records only what happens after it is in place; anything sent before it went to the real channel.
-
Why use travelTo() rather than freezeTime() for a rule about 09:00 IST?
freezeTime() fixes now() to whenever the run begins, so a due day built from today() still changes with the day the suite runs. travelTo() sets the exact moment the rule is about, so the test gives the same answer at any hour.
-
The reminder test ran at 09:00 IST on the 12th. Why did reminded_at read 03:30?
The app keeps UTC, as config/app.php sets, and India's clock is five and a half hours ahead of it.
Key takeaways
- A test double takes the place of something outside the code: fakes for Laravel’s services, mocks for your own contracts, time travel for the clock.
- A facade fake must be in place before the code runs, because it records only what happens after it.
travelTo()fixes the moment thatnow()returns, so a rule about 09:00 IST gives one result at any hour.- The app keeps UTC, so a reminder sent at 09:00 IST is stored as 03:30.
Terms in this lesson
- flaky test
- A test that sometimes passes and sometimes fails on the same code, because its result depends on something it does not control, such as the hour, the order of tests or random data.
- frozen clock
- A test clock that does not move: every call to now() returns the same moment until the test sets or moves it again.
- mock
- A test double that is told before the code runs which calls to expect, with which arguments and how often. A call that breaks those expectations fails the test.
- partial mock
- A double of a real class in which only the methods given expectations are replaced; every other method runs the class's own code.
- spy
- A test double that accepts every call and records it, so the test can check afterwards which calls were made and with what.
- test double
- Any object or setting a test puts in place of a real collaborator for one test, so the code can run without it: a fake, a mock, a spy or a fixed clock.
- time travel
- In a Laravel test, setting the moment that now() and Carbon return, with travelTo() for a fixed moment or travel() to move it, so a date rule can be tested at any hour.
References
- Laravel 13.x Mocking, and interacting with time (Laravel (laravel.com))
- Laravel 13.x Queues, testing (Laravel (laravel.com))
- Laravel 13.x Notifications, testing (Laravel (laravel.com))
- Laravel 13.x Events, testing (Laravel (laravel.com))
- Laravel 13.x File Storage, testing (Laravel (laravel.com))
- Laravel 13.x HTTP Client, testing and stray requests (Laravel (laravel.com))
- Laravel 13.x Task Scheduling, timezones (Laravel (laravel.com))
- Laravel 13.x Upgrade Guide, Str factories reset between tests (Laravel (laravel.com))
- laravel/framework 13.34.0, InteractsWithTime.php (Laravel (GitHub))
- laravel/framework 13.34.0, InteractsWithTestCaseLifecycle.php (Laravel (GitHub))
- Mockery, expectation declarations (Mockery (docs.mockery.io))
- PHPUnit 12.5 Manual, configuration and test order (PHPUnit (docs.phpunit.de))
Report a problem with this lesson
Kept only in this browser. Your Learn progress