---
title: "Testing — Notifications for Laravel | Roundly"
description: "Notifications::fake() records without running — every assertion, recorded operations, passthrough(), Pest expectations, the real pipeline and factories."
url: https://roundly-consulting.com/open-source/docs/notifications-for-laravel/testing
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Notifications for Laravel](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)

# Testing

Notifications::fake() swaps the manager for a NotificationsFake behind the facade and in the container, and returns it:

```php
use RoundlyConsulting\Notifications\Facades\Notifications;

it('asks the approvers', function (): void {
    $notifications = Notifications::fake();            // record-only: nothing is written, nothing is mailed
    $request = PurchaseRequest::factory()->create();

    $this->actingAs($request->author)->post("/requests/{$request->id}/submit")->assertRedirect();

    $notifications->assertSent('request.awaiting_approval', to: $approver, about: $request);
    $notifications->assertNotSent('security.alert');
});
```

- It is record-only: every mutating call — sends (also through type()->…->send(), for()->send() and sendNotification()), inbox mutations, preference writes, reminder schedule / sync / remind / cancel / acknowledge, revoke, sweeps, prunes, the inbox channel and calls through the model traits — is recorded and not executed. A faked send returns an empty SendResult.
- Reads (inbox()->paginate(), types(), preferences()->matrix()) pass through to the real store, and type registration passes through.
- Recipient resolution still runs, so assertSent(to: $user) works with Recipients::role(). Access checks, preferences, quiet hours and de-dup do not: an assertion proves the intent to notify, not a delivery.
- NotificationsFake extends the manager, so an injected NotificationsManager is faked and still type-checks. Calling an action directly is not recorded.
- The interop listener is never recorded or blocked by the fake.
- \->passthrough() records and also runs every operation.

## Assertions

Every assertion is on the fake and forwarded by the facade:

| Assertion | Passes when |
| --- | --- |
| `assertSent($type, to: ?Model\|Closure, about: ?Model, times: ?int)` | A send of the type (to the recipient, about the subject; exactly times) was recorded. A closure to receives each resolved recipient. |
| `assertNotSent($type) · assertNothingSent()` | No send of the type / no send at all. |
| `assertMarkedRead(?Model $recipient)` | markRead() or markAllRead(). |
| `assertArchived(?Model $recipient)` | archive(). |
| `assertPreferenceSet($user, $type, Channel $channel, bool $enabled)` | A mute, unmute or set of that channel to that value. |
| `assertMuted($user, $type)` | The type’s mail was muted. |
| `assertPreferencesUpdated($user, ?Closure $where)` | preferences($user)->update(); $where(PreferencesUpdate): bool narrows it. |
| `assertRemindersSynced($subject)` | Including the sync HasNotificationReminders runs on save. |
| `assertReminderScheduled($subject, $type)` | schedule() or remind(). |
| `assertRemindersCancelled($subject)` | cancel(). |
| `assertAcknowledged($subject, ?$type)` | acknowledge(). |
| `assertRevoked($subject, ?Model $recipient)` | revoke(). |
| `assertSwept()` | sweep() or notifications:sweep. |

```php
$fake = Notifications::fake();

$fake->assertSent('comment.posted', to: fn (User $user): bool => $user->is_admin, times: 2);
$fake->assertMarkedRead($user);
$fake->assertPreferencesUpdated($user, fn (PreferencesUpdate $update): bool => isset($update->channels['comment.posted']));
$fake->assertRemindersSynced($contract);          // also the sync HasNotificationReminders runs on save
$fake->assertAcknowledged($contract, 'contract.expiring');
$fake->assertRevoked($request, $formerMember);
$fake->assertSwept();

// The facade forwards every assertion too
Notifications::assertNothingSent();

// Record and still run
Notifications::fake()->passthrough();
```

## Recorded operations

$fake->recorded(?NotificationOperation $operation = null) returns the RecordedOperation records in call order — operation, context (the call’s arguments), the resolved recipients of a send, type and subject:

```php
use RoundlyConsulting\Notifications\Enums\NotificationOperation;

foreach ($fake->recorded(NotificationOperation::Send) as $send) {
    $send->operation;     // NotificationOperation::Send
    $send->type;          // 'request.awaiting_approval'
    $send->subject;       // the subject model
    $send->recipients;    // list<Model> — the resolved recipients
    $send->context;       // the call's arguments
}
```

## Pest expectations

Registered automatically through the package’s Pest plugin; they read the database:

| Expectation | Passes when |
| --- | --- |
| `toHaveUnreadInboxItems(int $count)` | Exactly this many unread, not archived items. |
| `toHaveInboxItem(string $type, ?Model $subject = null)` | An item of the type (about the subject). |
| `toHaveMuted(string $type)` | The type’s mail is off for the user. |

```php
expect($user)->toHaveUnreadInboxItems(1);
expect($user)->toHaveInboxItem('request.awaiting_approval', $request);
expect($user)->toHaveMuted('comment.posted');

// Where Pest plugin discovery is off
RoundlyConsulting\Notifications\Testing\NotificationExpectations::register();
```

## The real pipeline with Notification::fake()

To assert a delivery, run the real pipeline and fake Laravel’s notifications. Digests arrive as DigestMailNotification. Freeze time for quiet hours, digests and reminders, and run Notifications::sweep() to release what is due:

```php
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;
use RoundlyConsulting\Notifications\Mail\TypedMailNotification;

Notification::fake();

Notifications::send('security.alert', null, $user);

Notification::assertSentTo($user, TypedMailNotification::class, fn ($n) => $n->type === 'security.alert');
expect($user)->toHaveUnreadInboxItems(1);

// Quiet hours, digests and reminders: freeze time, then release what is due
Carbon::setTestNow('2026-11-10 07:01');
Notifications::sweep();
```

Notification::fake() dispatches no NotificationSending, so interop does not run under it.

## Factories

Every model ships a factory resolved through the model seam:

```php
InboxItem::factory()->recipient($user)->read()->create();
NotificationDelivery::factory()->recipient($user)->status(DeliveryStatus::Held)->create();
NotificationReminder::factory()->subject($contract)->create();
DigestRun::factory()->recipient($user)->create();
NotificationReceipt::factory()->create();
```

## Testing an app

- Publish both migration sets (notifications and options) — RefreshDatabase works on SQLite in tests.
- Define a policy or the view ability for every subject class your types use, or the first send throws MissingAccessCheckException.
- Turn the queue off (or use the sync connection) when a test needs mail to go out inline.
- Use on / off values in phpunit.xml:

```xml
<php>
    <env name="NOTIFICATIONS_ROUTES_ENABLED" value="on"/>
    <env name="NOTIFICATIONS_QUEUE_ENABLED" value="off"/>
</php>
```

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)
