Testing Notifications
Notification memiliki dua sisi yang menjawab pertanyaan berbeda — "apakah user sudah diberi tahu" dan "apakah user dapat menemukannya lagi nanti" — dan sebuah notification valid saja jika hanya memiliki salah satu dari keduanya. Package menyediakan assertion untuk kedua sisi tersebut. Bagian lain dari rantai notifikasi tetap menggunakan test Laravel biasa: event, database row, JSON endpoint, dan Inertia prop.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
use App\Models\User;
use PandaPanel\Notifications\Notification;
it('tells the user their export is ready', function (): void {
$user = User::factory()->create();
fakePanelNotifications();
Notification::make('export-ready')->title('Export ready')->send($user);
assertPanelNotificationSentTo($user, 'Export ready');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Helper tidak membutuhkan import karena di-autoload melalui bagian files Composer, sehingga langsung tersedia di test tanpa base class atau trait.
Helper yang tersedia
Ada lima free function di src/Testing/helpers.php. Semuanya dilindungi function_exists, sehingga aplikasi yang sudah memiliki function dengan nama yang sama tetap mempertahankan function miliknya.
| Function | Signature | Yang di-assert |
|---|---|---|
fakePanelNotifications | fakePanelNotifications(): void | — mulai merekam |
assertPanelNotificationSentTo | assertPanelNotificationSentTo(Authenticatable $user, ?string $title = null): void | broadcast |
assertNoPanelNotifications | assertNoPanelNotifications(): void | broadcast |
assertPanelNotificationStoredFor | assertPanelNotificationStoredFor(Authenticatable $user, ?string $title = null): void | database |
assertNoPanelNotificationsStoredFor | assertNoPanelNotificationsStoredFor(Authenticatable $user): void | database |
Setiap helper mendelegasikan pekerjaan ke PandaPanel\Testing\TestsNotifications, yang public jika test lebih nyaman menggunakan class tersebut secara langsung:
use PandaPanel\Testing\TestsNotifications;
TestsNotifications::fake();
TestsNotifications::assertSentTo($user, 'Export ready');
TestsNotifications::assertNothingSent();
TestsNotifications::assertStoredFor($user, 'Export ready');
TestsNotifications::assertNothingStoredFor($user);2
3
4
5
6
7
fakePanelNotifications()
Event::fake([PanelNotificationSent::class]);Fake sengaja dibuat sempit. Mem-fake seluruh event akan mematikan model event yang dibutuhkan Panel, sehingga test dapat lolos ketika lifecycle hook Resource sebenarnya rusak. Panggil helper sebelum kode yang sedang diuji — event yang di-dispatch sebelum fake dipasang tidak akan direkam.
Assertion untuk broadcast
use App\Models\User;
use PandaPanel\Notifications\Notification;
it('broadcasts to the right user', function (): void {
$user = User::factory()->create();
$other = User::factory()->create();
fakePanelNotifications();
Notification::make('saved')->title('Saved.')->send($user);
assertPanelNotificationSentTo($user); // any title
assertPanelNotificationSentTo($user, 'Saved.'); // this title
});
it('says nothing when the action was refused', function (): void {
fakePanelNotifications();
// … code that should notify nobody …
assertNoPanelNotifications();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Matching dilakukan pada $event->user->getAuthIdentifier() dan, jika title diberikan, pada $event->payload['title']. Yang dibandingkan adalah title setelah di-resolve, sehingga notification yang mengambil title dari Str::headline($name) dicocokkan terhadap hasil headline tersebut.
Assertion untuk database
Assertion ini membaca database, bukan event, karena pertanyaan yang diuji berbeda: broadcast yang tidak dipersist akan hilang ketika tab ditutup.
it('leaves a notification the user can find later', function (): void {
$user = User::factory()->create();
Notification::make('export')->title('Export ready')->persistent()->send($user);
assertPanelNotificationStoredFor($user, 'Export ready');
});
it('does not fill the bell with "Saved."', function (): void {
$user = User::factory()->create();
Notification::make('saved')->title('Saved.')->send($user);
assertNoPanelNotificationsStoredFor($user);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
Tidak dibutuhkan fake — dan fake juga tidak membantu — karena assertion ini melakukan query ke $user->notifications(). User model tanpa method notifications() dianggap tidak memiliki stored notification daripada melempar exception, sehingga assertNothingStoredFor() secara otomatis true pada model seperti itu.
Meng-assert payload secara langsung
Notification::toArray() bersifat pure, sehingga shape dapat diuji tanpa mengirim apa pun:
use PandaPanel\Notifications\Enums\NotificationColor;
use PandaPanel\Notifications\Notification;
it('takes its icon from its colour unless it names one', function (): void {
$default = Notification::make('a')->warning()->toArray();
$named = Notification::make('b')->warning()->icon('users')->toArray();
expect($default['icon'])->toBe('triangle-alert')
->and($named['icon'])->toBe('users')
->and($default['type'])->toBe('warning')
->and(NotificationColor::Danger->toastType())->toBe('error');
});2
3
4
5
6
7
8
9
10
11
12
Prinsip yang sama berlaku untuk bare broadcast event yang pada dasarnya adalah value object:
use PandaPanel\Broadcasting\PanelNotification;
$event = new PanelNotification($user, 'Export finished', 'success');
expect($event->broadcastAs())->toBe('panel.notification')
->and($event->broadcastWith())->toBe([
'type' => 'success',
'message' => 'Export finished',
'url' => null,
'urlLabel' => null,
])
->and($event->broadcastOn()[0]->name)->toBe('private-App.Models.User.'.$user->getKey());2
3
4
5
6
7
8
9
10
11
12
fakePanelNotifications() tidak mencakup PanelNotification karena helper hanya mem-fake PanelNotificationSent. Untuk event yang satunya, gunakan fake sendiri:
use Illuminate\Support\Facades\Event;
use PandaPanel\Broadcasting\PanelNotification;
Event::fake([PanelNotification::class]);
PanelNotification::dispatch($user, 'Export finished');
Event::assertDispatched(PanelNotification::class, fn (PanelNotification $event): bool =>
$event->message === 'Export finished' && $event->user->is($user));2
3
4
5
6
7
8
9
Menguji Notification Center
Gunakan HTTP test biasa terhadap endpoint Panel:
it('lists only the asking user\'s own notifications', function (): void {
$other = User::factory()->create();
Notification::make('mine')->title('Mine')->persistent()->send($this->admin);
Notification::make('theirs')->title('Theirs')->persistent()->send($other);
$response = $this->actingAs($this->admin)->getJson('/admin/notifications');
expect(array_column($response->json('notifications'), 'title'))->toBe(['Mine'])
->and($response->json('unread'))->toBe(1);
});
it('marks one read, and then all of them', function (): void {
Notification::make('a')->title('A')->persistent()->send($this->admin);
Notification::make('b')->title('B')->persistent()->send($this->admin);
$first = $this->admin->notifications()->first();
$this->actingAs($this->admin)
->postJson('/admin/notifications/read', ['id' => $first?->getKey()])
->assertOk()
->assertJsonPath('unread', 1);
$this->actingAs($this->admin)
->postJson('/admin/notifications/read')
->assertOk()
->assertJsonPath('unread', 0);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Security property ini layak ikut diuji pada suite aplikasi Anda karena mekanismenya memang tidak menggunakan policy:
it('cannot be pointed at another user\'s notification', function (): void {
$other = User::factory()->create();
Notification::make('theirs')->title('Theirs')->persistent()->send($other);
$this->actingAs($this->admin)
->postJson('/admin/notifications/read', ['id' => $other->notifications()->first()?->getKey()])
->assertOk();
// Matched nothing rather than 403'd — the same outcome, one fewer leak.
expect($other->unreadNotifications()->count())->toBe(1);
});2
3
4
5
6
7
8
9
10
11
12
Uji juga bahwa stored row diperlakukan sebagai data tidak tepercaya:
$this->admin->notifications()->create([
'id' => (string) Str::uuid(),
'type' => 'panel',
'data' => ['title' => 'Odd', 'color' => 'chartreuse', 'actions' => ['nonsense']],
'read_at' => null,
]);
$entry = $this->actingAs($this->admin)->getJson('/admin/notifications')->json('notifications.0');
expect($entry['color'])->toBe('info')
->and($entry['icon'])->toBe('info')
->and($entry['actions'])->toBe([]);2
3
4
5
6
7
8
9
10
11
12
Menguji shared props
use Inertia\Testing\AssertableInertia;
it('sends the unread count with every panel request', function (): void {
Notification::make('a')->title('A')->persistent()->send($this->admin);
$this->actingAs($this->admin)->get('/admin')
->assertInertia(function (AssertableInertia $page): void {
$notifications = $page->toArray()['props']['notifications'];
expect($notifications['enabled'])->toBeTrue()
->and($notifications['unread'])->toBe(1)
->and($notifications['indexUrl'])->toContain('/admin/notifications');
});
});2
3
4
5
6
7
8
9
10
11
12
13
14
Broadcasting prop membutuhkan aplikasi terlihat benar-benar memiliki broadcaster, sedangkan bare test skeleton biasanya tidak:
use Illuminate\Support\Facades\Config;
Config::set('broadcasting.default', 'reverb');
Config::set('broadcasting.connections.reverb.driver', 'reverb');
$this->actingAs($this->admin)->get('/admin')
->assertInertia(fn (AssertableInertia $page) => $page
->where('broadcasting.enabled', true)
->where('broadcasting.channel', 'App.Models.User.'.$this->admin->getKey()));2
3
4
5
6
7
8
9
Nyatakan configuration secara eksplisit daripada mengasumsikannya: channel hanya dikirim kepada aplikasi yang benar-benar dapat melakukan broadcast. Negative case juga layak diuji tersendiri — broadcasting.default bernilai null, default menunjuk connection yang tidak didefinisikan, serta driver null dan log semuanya menghasilkan enabled: false.
Menguji callback channel
Baca callback langsung dari broadcaster daripada menulis ulang rule:
use Illuminate\Support\Facades\Broadcast;
$callback = Broadcast::connection()->getChannels()->get('App.Models.User.{id}');
expect($callback)->not->toBeNull()
->and($callback($this->admin, $this->admin->getKey()))->toBeTrue()
->and($callback($this->admin, $other->getKey()))->toBeFalse();2
3
4
5
6
7
Menguji Flash Toast Bridge
Inertia menempatkan flash data di samping props pada page object, bukan di dalamnya, sehingga assertion prop Inertia tidak dapat menjangkaunya:
use Illuminate\Testing\TestResponse;
function flashedToast(TestResponse $response): ?array
{
$page = $response->viewData('page');
$toast = is_array($page) ? ($page['flash']['toast'] ?? null) : null;
return is_array($toast) ? $toast : null;
}
it('maps a conventional success flash onto the toast channel', function (): void {
$this->get('/__test/flash-success')->assertRedirect('/');
expect(flashedToast($this->get('/')))->toBe(['type' => 'success', 'message' => 'Saved.']);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Message di-flash pada satu request dan baru dirender pada request berikutnya, sehingga assertion selalu membutuhkan get() kedua.
Menguji notifikasi dari job
Panggil method job secara langsung. Kedua job package dibangun dari scalar, sehingga test ini murah:
use PandaPanel\Jobs\RunPanelImport;
it('tells the user an import failed, and why', function (): void {
Storage::fake(UserImporter::disk());
fakePanelNotifications();
$job = new RunPanelImport(UserImporter::class, 'imports/people.csv', ['name' => 0], $this->user->getKey(), 'admin');
$job->failed(new RuntimeException('column count mismatch on row 12'));
assertPanelNotificationSentTo($this->user, 'Import failed');
});2
3
4
5
6
7
8
9
10
11
12
13
failed() adalah method biasa. Memanggilnya secara langsung merupakan cara menguji failure path tanpa benar-benar membuat job gagal.
Hal yang perlu diperhatikan
- Fake sebelum eksekusi, assert setelahnya.
fakePanelNotifications()memasangEvent::fake, yang hanya merekam event yang di-dispatch setelah helper dipanggil. - Event yang di-fake tidak mengubah persistence.
Event::fakemenghentikan broadcast, bukan pemanggilannotify(), sehingga database assertion tetap bekerja ketika event di-fake — kedua sisi independen. - Title dibandingkan secara exact.
assertPanelNotificationSentTo($user, 'Export ready.')gagal terhadap'Export ready'. Hilangkan parameter title jika copy bukan fokus test. assertNothingStoredFor()lolos pada model non-Notifiable, karena tidak ada relation yang dapat ditanyakan. Assert trait secara terpisah jika itu property yang benar-benar ingin diuji.- Testbench tidak memiliki broadcaster. Assertion terhadap
broadcasting.channelmembutuhkanConfig::set()terlebih dahulu, jika tidak hasilnyafalse/nulldan test sebenarnya menguji kondisi yang salah.