Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 20 minutes free
Free: Fakes, mocks and time travel each replace one outside thing · One reminder test, with a fake and a moved clock. With a pass: 3 in-depth parts and 3 interview answers, about 14 minutes.

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

Interactive · step by step Four outside things, four stand-ins Step through what the fees code reaches outside itself and what a test puts in each place instead.
Four outside things, four stand-ins, step 6 of 6. Showing SMS provider, ExamplePay's API, Queue workers, The real clock, Notification fake, Http fake, Bus fake, travelTo(). In focus: SMS provider, Notification fake.recordedansweredrecordedsetSMS providerExamplePay's APIQueue workersThe real clockNotification fakeHttp fakeBus faketravelTo()

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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, and Http::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

  1. Question 1 of 2 A test calls Notification::fake(), then runs the reminder command, which calls notify() for one guardian. What happens to that reminder?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It is kept in memory, and the test can ask about it with assertSentTo()

    A facade fake replaces a Laravel service for one test. The code calls the service as usual, the fake records the call, and the assertions read that record afterwards.

  2. Question 2 of 2 The reminder code calls HolidayCalendar, an interface your team wrote. The test must fail if isHoliday() is called with any day other than the 15th. Which stand-in fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A Mockery mock of HolidayCalendar that expects isHoliday() with the 15th

    Fakes cover Laravel's services; for a class or an interface of your own, a Mockery mock states the expected call first and fails the test when the code breaks it.

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:

The test, the command and the notification

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:

Interactive · message by message One reminder test, from the clock to the assertions Step through the test that expects one reminder three days before the due day.
One reminder test, from the clock to the assertions, step 6 of 6. Messages so far: The test to Command: fees:remind; Command to The fake: FeeDueReminder; The test to The fake: assertSentTo.The testPHPUnitCommandfees:remindThe fakerecordsfees:remindFeeDueReminderassertSentTo

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. Factories write the rows. A student with a guardian and one pending instalment of 118000 paise, due on the 15th, three days ahead.
  4. 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.
  5. 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.
  6. 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.

Two reminder tests, a fake and a moved clock Python · reminder_fake_session.py
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

  1. Question 1 of 2 The clock reads 09:00 IST on the 13th. A pending instalment due on the 15th has never been reminded. How many reminders does fees:remind send for it?

    Type a number.

    A hint

    The command looks from today, in India, up to three days ahead, and skips instalments already reminded.

    Show the answer: Question 1 of 2

    Answer: 1 reminders

    On the 13th the window runs from the 13th to the 16th, so the 15th is inside it, and an instalment never reminded gets one SMS. The run then sets reminded_at, so later mornings skip it.

  2. Question 2 of 2 A new test runs the reminder command first and calls Notification::fake() on the next line. Its assertSentTo() fails. What went wrong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The fake records only what happens after it is in place, so the command ran with the real sender

    A fake swaps the service from the moment it is called. Everything the code did before that went to the real service, so the fake has nothing to report.

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.

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.

  1. Question 1 of 6 What does a Laravel facade fake such as Notification::fake() do?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: It replaces the service with a recorder for one test, and the test checks the record afterwards

    A facade fake swaps one Laravel service for a recorder. It is installed first, the code runs, and the test asks what was recorded, to whom and how often.

  2. Question 2 of 6 Which of these belong in a test of the reminder command, so that it never texts a real phone and gives the same result at any hour?

    Choose every answer that is right.

    Show the answer: Question 2 of 6

    Answer:

    • Notification::fake(), before the command runs
    • travelTo() a fixed moment, such as 09:00 IST on the 12th

    Two stand-ins cover the reminder: a fake for what leaves the app and time travel for the clock. With both, the test is offline and gives one answer.

  3. Question 3 of 6 Your code calls your own PdfRenderer class, which Laravel has no fake for. You want to run the code first and then check which calls it made, without stating them beforehand. What do you use?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: $this->spy(PdfRenderer::class), then assert the calls after the code ran

    A mock is told the calls first; a spy records them and is asked afterwards. Both are Mockery doubles for classes of your own.

  4. Question 4 of 6 In the reminder test, which line must come before $this->artisan('fees:remind')?

    Choose one answer.

    Show the answer: Question 4 of 6

    Answer: Notification::fake()

    Install the fake first, set the clock, run the command, then assert. The recorded test does exactly that.

  5. Question 5 of 6 The command ran at 09:00 IST on the 12th in an app that keeps UTC. What does reminded_at hold afterwards?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: 03:30 on the 12th

    The moment is the same; only the clock it is written in differs. The app's UTC writes 09:00 IST as 03:30, and a test that compares dates must use the same calendar as the code.

  6. Question 6 of 6 At 09:00 IST on the 11th, an instalment is due on the 15th, and the command sends nothing. Which change to the clock makes the same test send one reminder?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: $this->travel(1)->day()

    The window runs from today in India to three days ahead. Moving the clock one day forward brings the 15th inside it, and the command sends one reminder.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

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

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

  5. 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 that now() 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.