From 1cdbaaaacc3034d07089e48b9242cfe6306703e7 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 8 Apr 2026 14:19:09 +0700 Subject: [PATCH] Add comprehensive documentation for testing schemas and tables - Introduced `testing-schemas.md` with detailed instructions on filling forms, asserting schema states, validating forms, and testing visibility and existence of form fields and components. - Added `testing-tables.md` covering table rendering, column testing, searching, sorting, filtering, and summarizing, along with visibility and existence checks for filters and columns. --- docs/testing-actions.md | 525 ++++++++++++++++++++++++++++++++++ docs/testing-guidlines.md | 187 ------------ docs/testing-notifications.md | 87 ++++++ docs/testing-resources.md | 447 +++++++++++++++++++++++++++++ docs/testing-schemas.md | 474 ++++++++++++++++++++++++++++++ docs/testing-tables.md | 477 ++++++++++++++++++++++++++++++ 6 files changed, 2010 insertions(+), 187 deletions(-) create mode 100644 docs/testing-actions.md delete mode 100644 docs/testing-guidlines.md create mode 100644 docs/testing-notifications.md create mode 100644 docs/testing-resources.md create mode 100644 docs/testing-schemas.md create mode 100644 docs/testing-tables.md diff --git a/docs/testing-actions.md b/docs/testing-actions.md new file mode 100644 index 0000000..68d74f1 --- /dev/null +++ b/docs/testing-actions.md @@ -0,0 +1,525 @@ +# Testing Actions + +## Calling an Action in a Test + +You can call an action by passing its name or class to `callAction()`: + +```php +use function Pest\Livewire\livewire; + +it('can send invoices', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->callAction('send'); + + expect($invoice->refresh()) + ->isSent()->toBeTrue(); +}); +``` + +## Testing Table Actions + +To test table actions, you can use a `TestAction` object with the `table()` method. This object receives the name of the action you want to test, and replaces the name of the action in any testing method you want to use. For example: + +```php +use Filament\Actions\Testing\TestAction; +use function Pest\Livewire\livewire; + +$invoice = Invoice::factory()->create(); + +livewire(ListInvoices::class) + ->callAction(TestAction::make('send')->table($invoice)); + +livewire(ListInvoices::class) + ->assertActionVisible(TestAction::make('send')->table($invoice)); + +livewire(ListInvoices::class) + ->assertActionExists(TestAction::make('send')->table($invoice)); +``` + +## Testing Table Header Actions + +To test a header action, you can use the `table()` method without passing in a specific record to test with: + +```php +use Filament\Actions\Testing\TestAction; +use function Pest\Livewire\livewire; + +livewire(ListInvoices::class) + ->callAction(TestAction::make('create')->table()); + +livewire(ListInvoices::class) + ->assertActionVisible(TestAction::make('create')->table()); + +livewire(ListInvoices::class) + ->assertActionExists(TestAction::make('create')->table()); +``` + +## Testing Table Bulk Actions + +To test a bulk action, first call `selectTableRecords()` and pass in any records you want to select. Then, use the `TestAction`'s `bulk()` method to specify the action you want to test. For example: + +```php +use Filament\Actions\Testing\TestAction; +use function Pest\Livewire\livewire; + +$invoices = Invoice::factory()->count(3)->create(); + +livewire(ListInvoices::class) + ->selectTableRecords($invoices->pluck('id')->toArray()) + ->callAction(TestAction::make('send')->table()->bulk()); + +livewire(ListInvoices::class) + ->assertActionVisible(TestAction::make('send')->table()->bulk()); + +livewire(ListInvoices::class) + ->assertActionExists(TestAction::make('send')->table()->bulk()); +``` + +## Testing Actions in a Schema + +If an action belongs to a component in a resource's infolist, for example, if it is in the `belowContent()` method of an infolist entry, you can use the `TestAction` object with the `schemaComponent()` method. This object receives the name of the action you want to test and replaces the name of the action in any testing method you want to use. For example: + +```php +use Filament\Actions\Testing\TestAction; +use function Pest\Livewire\livewire; + +$invoice = Invoice::factory()->create(); + +livewire(EditInvoice::class) + ->callAction(TestAction::make('send')->schemaComponent('customer_id')); + +livewire(EditInvoice::class) + ->assertActionVisible(TestAction::make('send')->schemaComponent('customer_id')); + +livewire(EditInvoice::class) + ->assertActionExists(TestAction::make('send')->schemaComponent('customer_id')); +``` + +## Testing Actions Inside Another Action's Schema / Form + +If an action belongs to a component in another action's `schema()` (or `form()`), for example, if it is in the `belowContent()` method of a form field in an action modal, you can use the `TestAction` object with the `schemaComponent()` method. This object receives the name of the action you want to test and replaces the name of the action in any testing method you want to use. You should pass an array of `TestAction` objects in order, for example: + +```php +use Filament\Actions\Testing\TestAction; +use function Pest\Livewire\livewire; + +$invoice = Invoice::factory()->create(); + +livewire(ManageInvoices::class) + ->callAction([ + TestAction::make('view')->table($invoice), + TestAction::make('send')->schemaComponent('customer.name'), + ]); + +livewire(ManageInvoices::class) + ->assertActionVisible([ + TestAction::make('view')->table($invoice), + TestAction::make('send')->schemaComponent('customer.name'), + ]); + +livewire(ManageInvoices::class) + ->assertActionExists([ + TestAction::make('view')->table($invoice), + TestAction::make('send')->schemaComponent('customer.name'), + ]); +``` + +## Testing Resource getFormActions() + +For details on how to test custom actions in the `getFormActions()` of a resource page, refer to the Testing resources documentation. + +## Testing Forms in Action Modals + +To pass an array of data into an action, use the `data` parameter: + +```php +use function Pest\Livewire\livewire; + +it('can send invoices', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->callAction('send', data: [ + 'email' => $email = fake()->email(), + ]) + ->assertHasNoFormErrors(); + + expect($invoice->refresh()) + ->isSent()->toBeTrue() + ->recipient_email->toBe($email); +}); +``` + +If you ever need to only set an action's data without immediately calling it, you can use `fillForm()`: + +```php +use function Pest\Livewire\livewire; + +it('can send invoices', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->mountAction('send') + ->fillForm([ + 'email' => $email = fake()->email(), + ]); +``` + +## Testing Validation Errors in an Action Modal's Form + +`assertHasNoFormErrors()` is used to assert that no validation errors occurred when submitting the action form. + +To check if a validation error has occurred with the data, use `assertHasFormErrors()`, similar to `assertHasErrors()` in Livewire: + +```php +use function Pest\Livewire\livewire; + +it('can validate invoice recipient email', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->callAction('send', data: [ + 'email' => Str::random(), + ]) + ->assertHasFormErrors(['email' => ['email']]); +}); +``` + +To check if an action is pre-filled with data, you can use the `assertSchemaStateSet()` method: + +```php +use function Pest\Livewire\livewire; + +it('can send invoices to the primary contact by default', function () { + $invoice = Invoice::factory()->create(); + $recipientEmail = $invoice->company->primaryContact->email; + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->mountAction('send') + ->assertSchemaStateSet([ + 'email' => $recipientEmail, + ]) + ->callMountedAction() + ->assertHasNoFormErrors(); + + expect($invoice->refresh()) + ->isSent()->toBeTrue() + ->recipient_email->toBe($recipientEmail); +}); +``` + +## Testing the Content of an Action Modal + +To assert the content of a modal, you should first mount the action (rather than call it which closes the modal). You can then use `assertMountedActionModalSee()`, `assertMountedActionModalDontSee()`, `assertMountedActionModalSeeHtml()` or `assertMountedActionModalDontSeeHtml()` to assert the modal contains the content that you expect it to: + +```php +use function Pest\Livewire\livewire; + +it('confirms the target address before sending', function () { + $invoice = Invoice::factory()->create(); + $recipientEmail = $invoice->company->primaryContact->email; + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->mountAction('send') + ->assertMountedActionModalSee($recipientEmail); +}); +``` + +## Testing the Existence of an Action + +To ensure that an action exists or doesn't, you can use the `assertActionExists()` or `assertActionDoesNotExist()` method: + +```php +use function Pest\Livewire\livewire; + +it('can send but not unsend invoices', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionExists('send') + ->assertActionDoesNotExist('unsend'); +}); +``` + +You may pass a function as an additional argument to assert that an action passes a given "truth test". This is useful for asserting that an action has a specific configuration: + +```php +use Filament\Actions\Action; +use function Pest\Livewire\livewire; + +it('has the correct description', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionExists('send', function (Action $action): bool { + return $action->getModalDescription() === 'This will send an email to the customer\'s primary address, with the invoice attached as a PDF'; + }); +}); +``` + +## Testing the Visibility of an Action + +To ensure an action is hidden or visible for a user, you can use the `assertActionHidden()` or `assertActionVisible()` methods: + +```php +use function Pest\Livewire\livewire; + +it('can only print invoices', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionHidden('send') + ->assertActionVisible('print'); +}); +``` + +## Testing Disabled Actions + +To ensure an action is enabled or disabled for a user, you can use the `assertActionEnabled()` or `assertActionDisabled()` methods: + +```php +use function Pest\Livewire\livewire; + +it('can only print a sent invoice', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionDisabled('send') + ->assertActionEnabled('print'); +}); +``` + +To ensure sets of actions exist in the correct order, you can use `assertActionListInOrder()`: + +```php +use function Pest\Livewire\livewire; + +it('can have actions in order', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionListInOrder(['send', 'export']); +}); +``` + +To check if an action is hidden to a user, you can use the `assertActionHidden()` method: + +```php +use function Pest\Livewire\livewire; + +it('can not send invoices', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionHidden('send'); +}); +``` + +## Testing the Label of an Action + +To ensure an action has the correct label, you can use `assertActionHasLabel()` and `assertActionDoesNotHaveLabel()`: + +```php +use function Pest\Livewire\livewire; + +it('send action has correct label', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionHasLabel('send', 'Email Invoice') + ->assertActionDoesNotHaveLabel('send', 'Send'); +}); +``` + +## Testing the Icon of an Action + +To ensure an action's button is showing the correct icon, you can use `assertActionHasIcon()` or `assertActionDoesNotHaveIcon()`: + +```php +use function Pest\Livewire\livewire; + +it('when enabled the send button has correct icon', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionEnabled('send') + ->assertActionHasIcon('send', 'envelope-open') + ->assertActionDoesNotHaveIcon('send', 'envelope'); +}); +``` + +## Testing the Color of an Action + +To ensure that an action's button is displaying the right color, you can use `assertActionHasColor()` or `assertActionDoesNotHaveColor()`: + +```php +use function Pest\Livewire\livewire; + +it('actions display proper colors', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionHasColor('delete', 'danger') + ->assertActionDoesNotHaveColor('print', 'danger'); +}); +``` + +## Testing the URL of an Action + +To ensure an action has the correct URL, you can use `assertActionHasUrl()`, `assertActionDoesNotHaveUrl()`, `assertActionShouldOpenUrlInNewTab()`, and `assertActionShouldNotOpenUrlInNewTab()`: + +```php +use function Pest\Livewire\livewire; + +it('links to the correct Filament sites', function () { + $invoice = Invoice::factory()->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->assertActionHasUrl('filament', 'https://filamentphp.com/') + ->assertActionDoesNotHaveUrl('filament', 'https://github.com/filamentphp/filament') + ->assertActionShouldOpenUrlInNewTab('filament') + ->assertActionShouldNotOpenUrlInNewTab('github'); +}); +``` + +## Testing Action Arguments + +To test action arguments, you can use a `TestAction` object with the `arguments()` method. This object receives the name of the action you want to test and replaces the name of the action in any testing method you want to use. For example: + +```php +use Filament\Actions\Testing\TestAction; +use function Pest\Livewire\livewire; + +$invoice = Invoice::factory()->create(); + +livewire(ManageInvoices::class) + ->callAction(TestAction::make('send')->arguments(['invoice' => $invoice->getKey()])); + +livewire(ManageInvoices::class) + ->assertActionVisible(TestAction::make('send')->arguments(['invoice' => $invoice->getKey()])); + +livewire(ManageInvoices::class) + ->assertActionExists(TestAction::make('send')->arguments(['invoice' => $invoice->getKey()])); +``` + +## Testing if an Action Has Been Halted + +To check if an action has been halted, you can use `assertActionHalted()`: + +```php +use function Pest\Livewire\livewire; + +it('stops sending if invoice has no email address', function () { + $invoice = Invoice::factory(['email' => null])->create(); + + livewire(EditInvoice::class, [ + 'invoice' => $invoice, + ]) + ->callAction('send') + ->assertActionHalted('send'); +}); +``` + +## Using Action Class Names in Tests + +Filament includes a host of prebuilt actions such as `CreateAction`, `EditAction` and `DeleteAction`, and you can use these class names in your tests instead of action names, for example: + +```php +use Filament\Actions\CreateAction; +use function Pest\Livewire\livewire; + +livewire(ManageInvoices::class) + ->callAction(CreateAction::class); +``` + +If you have your own action classes in your app with a `make()` method, the name of your action is not discoverable by Filament unless it runs the `make()` method, which is not efficient. To use your own action class names in your tests, you can add an `#[ActionName]` attribute to your action class, which Filament can use to discover the name of your action. The name passed to the `#[ActionName]` attribute should be the same as the name of the action you would normally use in your tests. For example: + +```php +use Filament\Actions\Action; +use Filament\Actions\ActionName; + +#[ActionName('send')] +class SendInvoiceAction +{ + public static function make(): Action + { + return Action::make('send') + ->requiresConfirmation() + ->action(function () { + // ... + }); + } +} +``` + +Now, you can use the class name in your tests: + +```php +use App\Filament\Resources\Invoices\Actions\SendInvoiceAction; +use Filament\Actions\Testing\TestAction; +use function Pest\Livewire\livewire; + +$invoice = Invoice::factory()->create(); + +livewire(ManageInvoices::class) + ->callAction(TestAction::make(SendInvoiceAction::class)->table($invoice)); +``` + +If you have an action class that extends the `Action` class, you can add a `getDefaultName()` static method to the class, which will be used to discover the name of the action. It also allows users to omit the name of the action from the `make()` method when instantiating it. For example: + +```php +use Filament\Actions\Action; + +class SendInvoiceAction extends Action +{ + public static function getDefaultName(): string + { + return 'send'; + } + + protected function setUp(): void + { + parent::setUp(); + + $this + ->requiresConfirmation() + ->action(function () { + // ... + }); + } +} +``` diff --git a/docs/testing-guidlines.md b/docs/testing-guidlines.md deleted file mode 100644 index 97baedd..0000000 --- a/docs/testing-guidlines.md +++ /dev/null @@ -1,187 +0,0 @@ -# Panduan Pengujian (Testing Guidelines) Filament Resource - -Dokumen ini berisi standar dan pola pengujian untuk semua Filament Resource dalam aplikasi ini menggunakan **Pest** dan **Livewire Test Utility**. - ---- - -## 1. Persiapan (Setup) - -Setiap file test harus menggunakan trait `RefreshDatabase` dan menyiapkan environment di dalam blok `beforeEach`. - -### Komponen Wajib Setup: -- **User**: Buat user aktif menggunakan factory. -- **Permissions**: Daftarkan permission yang relevan dengan Resource (format: `Action:Model`). -- **Role**: Assign permission ke user tersebut. - -```php -uses(RefreshDatabase::class); - -beforeEach(function () { - $this->user = User::factory()->create(['is_active' => IsActive::ACTIVE]); - - // Contoh untuk Resource 'Post' - $permissions = [ - 'ViewAny:Post', - 'Create:Post', - 'Update:Post', - 'Delete:Post', - 'Restore:Post', - 'ForceDelete:Post', - ]; - - foreach ($permissions as $permission) { - Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'web']); - } - - $this->user->givePermissionTo($permissions); -}); -``` - ---- - -## 2. Kategori Pengujian - -### A. Rendering & Otorisasi -Memastikan halaman dapat diakses oleh user yang berhak dan ditolak untuk yang tidak berhak. - -- **Positif**: `assertSuccessful()`. -- **Negatif**: `assertForbidden()`. - -```php -test('halaman list dapat diakses', function () { - $this->actingAs($this->user); - Livewire::test(ManagePosts::class)->assertSuccessful(); -}); - -test('user tanpa akses ditolak mengakses halaman', function () { - $guest = User::factory()->create(); - $this->actingAs($guest); - Livewire::test(ManagePosts::class)->assertForbidden(); -}); -``` - -### B. Menampilkan Data (Listing) -Memastikan tabel menampilkan data yang sesuai dan filter berfungsi (jika ada). - -```php -test('dapat menampilkan daftar data', function () { - $posts = Post::factory()->count(3)->create(); - $this->actingAs($this->user); - - Livewire::test(ManagePosts::class) - ->call('loadTable') // Jika menggunakan deferLoading - ->assertCanSeeTableRecords($posts); -}); -``` - -### C. Operasi CRUD (Create, Read, Update, Delete) -Gunakan `mountAction` untuk action di level halaman (seperti 'create') dan `mountTableAction` untuk action di baris tabel (seperti 'edit'). - -```php -// Create -test('dapat membuat data baru', function () { - $this->actingAs($this->user); - Livewire::test(ManagePosts::class) - ->mountAction('create') - ->setActionData(['title' => 'Judul Baru']) - ->callMountedAction() - ->assertHasNoActionErrors(); - - $this->assertDatabaseHas('posts', ['title' => 'Judul Baru']); -}); - -// Edit -test('dapat mengubah data', function () { - $post = Post::factory()->create(); - $this->actingAs($this->user); - - Livewire::test(ManagePosts::class) - ->mountTableAction('edit', $post) - ->setActionData(['title' => 'Judul Update']) - ->callMountedTableAction() - ->assertHasNoActionErrors(); - - expect($post->refresh()->title)->toBe('Judul Update'); -}); -``` - -### D. Validasi Form -Uji setiap aturan validasi kritis (required, unique, max, dll). - -```php -test('validasi: judul wajib diisi', function () { - $this->actingAs($this->user); - Livewire::test(ManagePosts::class) - ->mountAction('create') - ->setActionData(['title' => '']) - ->callMountedAction() - ->assertHasActionErrors(['title' => 'required']); -}); -``` - -### E. Soft Delete & Trash -Pengujian untuk fitur restore dan force delete biasanya membutuhkan role **Developer**. - -```php -test('dapat menghapus data (soft delete)', function () { - $post = Post::factory()->create(); - $this->actingAs($this->user); - - Livewire::test(ManagePosts::class)->callTableAction('delete', $post); - $this->assertSoftDeleted($post); -}); - -test('dapat merestore data yang dihapus', function () { - // Berikan role Developer agar bisa melihat filter 'trashed' - $roleDev = Role::firstOrCreate(['name' => RoleEnum::DEVELOPER->value]); - $this->user->assignRole($roleDev); - - $post = Post::factory()->create(['deleted_at' => now()]); - $this->actingAs($this->user); - - Livewire::test(ManagePosts::class) - ->filterTable('trashed', 'with') - ->callTableAction('restore', $post); - - $this->assertDatabaseHas('posts', ['id' => $post->id, 'deleted_at' => null]); -}); -``` - -### F. Fitur Khusus: Filter & Toggle -Jangan lupa menguji kolom toggle dan filter kustom. - -```php -// Toggle Status -test('dapat mengubah status is_active via toggle', function () { - $post = Post::factory()->create(['is_active' => false]); - $this->actingAs($this->user); - - Livewire::test(ManagePosts::class) - ->call('updateTableColumnState', 'is_active', $post->id, true); - - expect($post->refresh()->is_active)->toBeTrue(); -}); - -// Filter -test('dapat memfilter data berdasarkan kategori', function () { - $postA = Post::factory()->create(['category' => 'A']); - $postB = Post::factory()->create(['category' => 'B']); - - $this->actingAs($this->user); - - Livewire::test(ManagePosts::class) - ->filterTable('category', 'A') - ->assertCanSeeTableRecords([$postA]) - ->assertCanNotSeeTableRecords([$postB]); -}); -``` - ---- - -## 3. Praktik Terbaik (Best Practices) - -1. **Granularitas**: Satu test case fokus pada satu aturan validasi atau satu alur logika. -2. **Keamanan**: Selalu sertakan test untuk user yang tidak memiliki permission (unauthorized). -3. **Clean Up**: Gunakan `RefreshDatabase` agar data antar test tidak saling mengganggu. -4. **Factory**: Pastikan factory menghasilkan data yang valid sesuai integritas database. -5. **UI State**: Gunakan `assertActionHidden()` untuk memastikan tombol (seperti 'edit' atau 'delete') benar-benar tersembunyi bagi user yang tidak punya akses. diff --git a/docs/testing-notifications.md b/docs/testing-notifications.md new file mode 100644 index 0000000..cbf3dee --- /dev/null +++ b/docs/testing-notifications.md @@ -0,0 +1,87 @@ +# Testing Notifications + +## Testing Session Notifications + +To check if a notification was sent using the session, use the `assertNotified()` helper: + +```php +use function Pest\Livewire\livewire; + +it('sends a notification', function () { + livewire(CreatePost::class) + ->assertNotified(); +}); +``` + +```php +use Filament\Notifications\Notification; + +it('sends a notification', function () { + Notification::assertNotified(); +}); +``` + +```php +use function Filament\Notifications\Testing\assertNotified; + +it('sends a notification', function () { + assertNotified(); +}); +``` + +You may optionally pass a notification title to test for: + +```php +use Filament\Notifications\Notification; +use function Pest\Livewire\livewire; + +it('sends a notification', function () { + livewire(CreatePost::class) + ->assertNotified('Unable to create post'); +}); +``` + +Or test if the exact notification was sent: + +```php +use Filament\Notifications\Notification; +use function Pest\Livewire\livewire; + +it('sends a notification', function () { + livewire(CreatePost::class) + ->assertNotified( + Notification::make() + ->danger() + ->title('Unable to create post') + ->body('Something went wrong.'), + ); +}); +``` + +Conversely, you can assert that a notification was not sent: + +```php +use Filament\Notifications\Notification; +use function Pest\Livewire\livewire; + +it('does not send a notification', function () { + livewire(CreatePost::class) + ->assertNotNotified() + // or + ->assertNotNotified('Unable to create post') + // or + ->assertNotNotified( + Notification::make() + ->danger() + ->title('Unable to create post') + ->body('Something went wrong.'), + ); +}); +``` + // or + ->assertNotNotified( + Notification::make() + ->danger() + ->title('Unable to create post') + ->body('Something went wrong.'), + ); diff --git a/docs/testing-resources.md b/docs/testing-resources.md new file mode 100644 index 0000000..c754bc2 --- /dev/null +++ b/docs/testing-resources.md @@ -0,0 +1,447 @@ +# Testing Resources + +## Authenticating as a User + +Ensure that you are authenticated to access the app in your TestCase: + +```php +use App\Models\User; + +protected function setUp(): void +{ + parent::setUp(); + + $this->actingAs(User::factory()->create()); +} +``` + +Alternatively, if you are using Pest you can use a `beforeEach()` function at the top of your test file to authenticate: + +```php +use App\Models\User; + +beforeEach(function () { + $user = User::factory()->create(); + + actingAs($user); +}); +``` + +## Testing a Resource List Page + +To test if the list page is able to load, test the list page as a Livewire component, and call `assertOk()` to ensure that the HTTP response was 200 OK. You can also use the `assertCanSeeTableRecords()` method to check if records are being displayed in the table: + +```php +use App\Filament\Resources\Users\Pages\ListUsers; +use App\Models\User; + +it('can load the page', function () { + $users = User::factory()->count(5)->create(); + + livewire(ListUsers::class) + ->assertOk() + ->assertCanSeeTableRecords($users); +}); +``` + +To test the table on the list page, you should visit the Testing tables section. To test any actions in the header of the page or actions in the table, you should visit the Testing actions section. Below are some common examples of other tests that you can run on the list page. + +To test that the table search is working, you can use the `searchTable()` method to search for a specific record. You can also use the `assertCanSeeTableRecords()` and `assertCanNotSeeTableRecords()` methods to check if the correct records are being displayed in the table: + +```php +use App\Filament\Resources\Users\Pages\ListUsers; +use App\Models\User; + +it('can search users by `name` or `email`', function () { + $users = User::factory()->count(5)->create(); + + livewire(ListUsers::class) + ->assertCanSeeTableRecords($users) + ->searchTable($users->first()->name) + ->assertCanSeeTableRecords($users->take(1)) + ->assertCanNotSeeTableRecords($users->skip(1)) + ->searchTable($users->last()->email) + ->assertCanSeeTableRecords($users->take(-1)) + ->assertCanNotSeeTableRecords($users->take($users->count() - 1)); +}); +``` + +To test that the table sorting is working, you can use the `sortTable()` method to sort the table by a specific column. You can also use the `assertCanSeeTableRecords()` method to check if the records are being displayed in the correct order: + +```php +use App\Filament\Resources\Users\Pages\ListUsers; +use App\Models\User; + +it('can sort users by `name`', function () { + $users = User::factory()->count(5)->create(); + + livewire(ListUsers::class) + ->assertCanSeeTableRecords($users) + ->sortTable('name') + ->assertCanSeeTableRecords($users->sortBy('name'), inOrder: true) + ->sortTable('name', 'desc') + ->assertCanSeeTableRecords($users->sortByDesc('name'), inOrder: true); +}); +``` + +To test that the table filtering is working, you can use the `filterTable()` method to filter the table by a specific column. You can also use the `assertCanSeeTableRecords()` and `assertCanNotSeeTableRecords()` methods to check if the correct records are being displayed in the table: + +```php +use App\Filament\Resources\Users\Pages\ListUsers; +use App\Models\User; + +it('can filter users by `locale`', function () { + $users = User::factory()->count(5)->create(); + + livewire(ListUsers::class) + ->assertCanSeeTableRecords($users) + ->filterTable('locale', $users->first()->locale) + ->assertCanSeeTableRecords($users->where('locale', $users->first()->locale)) + ->assertCanNotSeeTableRecords($users->where('locale', '!=', $users->first()->locale)); +}); +``` + +To test that the table bulk actions are working, you can use the `selectTableRecords()` method to select multiple records in the table. You can also use the `callAction()` method to call a specific action on the selected records: + +```php +use App\Filament\Resources\Users\Pages\ListUsers; +use App\Models\User; +use Filament\Actions\Testing\TestAction; +use function Pest\Laravel\assertDatabaseMissing; + +it('can bulk delete users', function () { + $users = User::factory()->count(5)->create(); + + livewire(ListUsers::class) + ->assertCanSeeTableRecords($users) + ->selectTableRecords($users) + ->callAction(TestAction::make(DeleteBulkAction::class)->table()->bulk()) + ->assertNotified() + ->assertCanNotSeeTableRecords($users); + + $users->each(fn (User $user) => assertDatabaseMissing($user)); +}); +``` + +## Testing a Resource Create Page + +To test if the create page is able to load, test the create page as a Livewire component, and call `assertOk()` to ensure that the HTTP response was 200 OK: + +```php +use App\Filament\Resources\Users\Pages\CreateUser; +use App\Models\User; + +it('can load the page', function () { + livewire(CreateUser::class) + ->assertOk(); +}); +``` + +To test the form on the create page, you should visit the Testing schemas section. To test any actions in the header of the page or in the form, you should visit the Testing actions section. Below are some common examples of other tests that you can run on the create page. + +To test that the form is creating records correctly, you can use the `fillForm()` method to fill in the form fields, and then use the `call('create')` method to create the record. You can also use the `assertNotified()` method to check if a notification was displayed, and the `assertRedirect()` method to check if the user was redirected to another page: + +```php +use App\Filament\Resources\Users\Pages\CreateUser; +use App\Models\User; +use function Pest\Laravel\assertDatabaseHas; + +it('can create a user', function () { + $newUserData = User::factory()->make(); + + livewire(CreateUser::class) + ->fillForm([ + 'name' => $newUserData->name, + 'email' => $newUserData->email, + ]) + ->call('create') + ->assertNotified() + ->assertRedirect(); + + assertDatabaseHas(User::class, [ + 'name' => $newUserData->name, + 'email' => $newUserData->email, + ]); +}); +``` + +To test that the form is validating properly, you can use the `fillForm()` method to fill in the form fields, and then use the `call('create')` method to create the record. You can also use the `assertHasFormErrors()` method to check if the form has any errors, and the `assertNotNotified()` method to check if no notification was displayed. You can also use the `assertNoRedirect()` method to check if the user was not redirected to another page. In this example, we use a Pest dataset to test multiple rules without having to repeat the test code: + +```php +use App\Filament\Resources\Users\Pages\CreateUser; +use App\Models\User; +use Illuminate\Support\Str; + +it('validates the form data', function (array $data, array $errors) { + $newUserData = User::factory()->make(); + + livewire(CreateUser::class) + ->fillForm([ + 'name' => $newUserData->name, + 'email' => $newUserData->email, + ...$data, + ]) + ->call('create') + ->assertHasFormErrors($errors) + ->assertNotNotified() + ->assertNoRedirect(); +})->with([ + '`name` is required' => [['name' => null], ['name' => 'required']], + '`name` is max 255 characters' => [['name' => Str::random(256)], ['name' => 'max']], + '`email` is a valid email address' => [['email' => Str::random()], ['email' => 'email']], + '`email` is required' => [['email' => null], ['email' => 'required']], + '`email` is max 255 characters' => [['email' => Str::random(256)], ['email' => 'max']], +]); +``` + +## Testing a Resource Edit Page + +To test if the edit page is able to load, test the edit page as a Livewire component, and call `assertOk()` to ensure that the HTTP response was 200 OK. You can also use the `assertSchemaStateSet()` method to check if the form fields are set to the correct values: + +```php +use App\Filament\Resources\Users\Pages\EditUser; +use App\Models\User; + +it('can load the page', function () { + $user = User::factory()->create(); + + livewire(EditUser::class, [ + 'record' => $user->id, + ]) + ->assertOk() + ->assertSchemaStateSet([ + 'name' => $user->name, + 'email' => $user->email, + ]); +}); +``` + +To test the form on the edit page, you should visit the Testing schemas section. To test any actions in the header of the page or in the form, you should visit the Testing actions section. Below are some common examples of other tests that you can run on the edit page. + +```php +use App\Filament\Resources\Users\Pages\EditUser; +use App\Models\User; +use function Pest\Laravel\assertDatabaseHas; + +it('can update a user', function () { + $user = User::factory()->create(); + + $newUserData = User::factory()->make(); + + livewire(EditUser::class, [ + 'record' => $user->id, + ]) + ->fillForm([ + 'name' => $newUserData->name, + 'email' => $newUserData->email, + ]) + ->call('save') + ->assertNotified(); + + assertDatabaseHas(User::class, [ + 'id' => $user->id, + 'name' => $newUserData->name, + 'email' => $newUserData->email, + ]); +}); +``` + +To test that the form is validating properly, you can use the `fillForm()` method to fill in the form fields, and then use the `call('save')` method to save the record. You can also use the `assertHasFormErrors()` method to check if the form has any errors, and the `assertNotNotified()` method to check if no notification was displayed. In this example, we use a Pest dataset to test multiple rules without having to repeat the test code: + +```php +use App\Filament\Resources\Users\Pages\EditUser; +use App\Models\User; +use Illuminate\Support\Str; + +it('validates the form data', function (array $data, array $errors) { + $user = User::factory()->create(); + + $newUserData = User::factory()->make(); + + livewire(EditUser::class, [ + 'record' => $user->id, + ]) + ->fillForm([ + 'name' => $newUserData->name, + 'email' => $newUserData->email, + ...$data, + ]) + ->call('save') + ->assertHasFormErrors($errors) + ->assertNotNotified(); +})->with([ + '`name` is required' => [['name' => null], ['name' => 'required']], + '`name` is max 255 characters' => [['name' => Str::random(256)], ['name' => 'max']], + '`email` is a valid email address' => [['email' => Str::random()], ['email' => 'email']], + '`email` is required' => [['email' => null], ['email' => 'required']], + '`email` is max 255 characters' => [['email' => Str::random(256)], ['email' => 'max']], +]); +``` + +To test that an action is working, such as the `DeleteAction`, you can use the `callAction()` method to call the delete action. You can also use the `assertNotified()` method to check if a notification was displayed, and the `assertRedirect()` method to check if the user was redirected to another page: + +```php +use App\Filament\Resources\Users\Pages\EditUser; +use App\Models\User; +use Filament\Actions\DeleteAction; +use function Pest\Laravel\assertDatabaseMissing; + +it('can delete a user', function () { + $user = User::factory()->create(); + + livewire(EditUser::class, [ + 'record' => $user->id, + ]) + ->callAction(DeleteAction::class) + ->assertNotified() + ->assertRedirect(); + + assertDatabaseMissing($user); +}); +``` + +## Testing a Resource View Page + +To test if the view page is able to load, test the view page as a Livewire component, and call `assertOk()` to ensure that the HTTP response was 200 OK. You can also use the `assertSchemaStateSet()` method to check if the infolist entries are set to the correct values: + +```php +use App\Filament\Resources\Users\Pages\ViewUser; +use App\Models\User; + +it('can load the page', function () { + $user = User::factory()->create(); + + livewire(ViewUser::class, [ + 'record' => $user->id, + ]) + ->assertOk() + ->assertSchemaStateSet([ + 'name' => $user->name, + 'email' => $user->email, + ]); +}); +``` + +To test the infolist on the view page, you should visit the Testing schemas section. To test any actions in the header of the page or in the infolist, you should visit the Testing actions section. + +## Testing Relation Managers + +To test if a relation manager is rendered on a page, such as the edit page of a resource, you can use the `assertSeeLivewire()` method to check if the relation manager is being rendered: + +```php +use App\Filament\Resources\Users\Pages\EditUser; +use App\Filament\Resources\Users\RelationManagers\PostsRelationManager; +use App\Models\User; + +it('can load the relation manager', function () { + $user = User::factory()->create(); + + livewire(EditUser::class, [ + 'record' => $user->id, + ]) + ->assertSeeLivewire(PostsRelationManager::class); +}); +``` + +Since relation managers are Livewire components, you can also test a relation manager's functionality itself, like its ability to load successfully with a 200 OK response, with the correct records in the table. When testing a relation manager, you need to pass in the `ownerRecord`, which is the record from the resource you are inside, and the `pageClass`, which is the class of the page you are on: + +```php +use App\Filament\Resources\Users\Pages\EditUser; +use App\Filament\Resources\Users\RelationManagers\PostsRelationManager; +use App\Models\Post; +use App\Models\User; + +it('can load the relation manager', function () { + $user = User::factory() + ->has(Post::factory()->count(5)) + ->create(); + + livewire(PostsRelationManager::class, [ + 'ownerRecord' => $user, + 'pageClass' => EditUser::class, + ]) + ->assertOk() + ->assertCanSeeTableRecords($user->posts); +}); +``` + +You can test searching, sorting, and filtering in the same way as you would on a resource list page. + +You can also test actions, for example, the `CreateAction` in the header of the table: + +```php +use App\Filament\Resources\Users\Pages\EditUser; +use App\Filament\Resources\Users\RelationManagers\PostsRelationManager; +use App\Models\Post; +use App\Models\User; +use Filament\Actions\Testing\TestAction; +use function Pest\Laravel\assertDatabaseHas; + +it('can create a post', function () { + $user = User::factory()->create(); + + $newPostData = Post::factory()->make(); + + livewire(PostsRelationManager::class, [ + 'ownerRecord' => $user, + 'pageClass' => EditUser::class, + ]) + ->callAction(TestAction::make(CreateAction::class)->table(), [ + 'title' => $newPostData->title, + 'content' => $newPostData->content, + ]) + ->assertNotified(); + + assertDatabaseHas(Post::class, [ + 'title' => $newPostData->title, + 'content' => $newPostData->content, + 'user_id' => $user->id, + ]); +}); +``` + +## Testing Create / Edit Page getFormActions() + +When testing actions in `getFormActions()` on a resource page, use the `schemaComponent()` method targeting the `form-actions` key in the content schema. For example, if you have a custom `Action::make('createAndVerifyEmail')` action in the `getFormActions()` method of your `CreateUser` page, you can test it like this: + +```php +use App\Filament\Resources\Users\Pages\CreateUser; +use App\Models\User; +use Filament\Actions\Testing\TestAction; + +it('can create a user and verify their email address', function () { + livewire(CreateUser::class) + ->fillForm([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]) + ->callAction(TestAction::make('createAndVerifyEmail')->schemaComponent('form-actions', schema: 'content')); + + expect(User::query()->where('email', 'test@example.com')->first()) + ->hasVerifiedEmail()->toBeTrue(); +}); +``` + +## Testing Multiple Panels + +If you have multiple panels and you would like to test a non-default panel, you will need to tell Filament which panel you are testing. This can be done in the `setUp()` method of the test case, or you can do it at the start of a particular test. Filament usually does this in a middleware when you access the panel through a request, so if you're not making a request in your test like when testing a Livewire component, you need to set the current panel manually: + +```php +use Filament\Facades\Filament; + +Filament::setCurrentPanel('app'); // Where `app` is the ID of the panel you want to test. +``` + +## Testing Multi-Tenant Panels + +When testing resources in multi-tenant panels, you may need to call `Filament::bootCurrentPanel()` after setting the tenant in order to apply tenant scopes and model event listeners: + +```php +use Filament\Facades\Filament; + +$team = Team::factory()->create(); + +Filament::setTenant($this->team); +Filament::setCurrentPanel('admin'); +Filament::bootCurrentPanel(); +``` diff --git a/docs/testing-schemas.md b/docs/testing-schemas.md new file mode 100644 index 0000000..3cff67f --- /dev/null +++ b/docs/testing-schemas.md @@ -0,0 +1,474 @@ +# Testing Schemas + +## Filling a Form in a Test + +To fill a form with data, pass the data to `fillForm()`: + +```php +use function Pest\Livewire\livewire; + +livewire(CreatePost::class) + ->fillForm([ + 'title' => fake()->sentence(), + // ... + ]); +``` + +If you have multiple schemas on a Livewire component, you can specify which form you want to fill using `fillForm([...], 'createPostForm')`. + +## Testing Form Field and Infolist Entry State + +To check that a form has data, use `assertSchemaStateSet()`: + +```php +use Illuminate\Support\Str; +use function Pest\Livewire\livewire; + +it('can automatically generate a slug from the title', function () { + $title = fake()->sentence(); + + livewire(CreatePost::class) + ->fillForm([ + 'title' => $title, + ]) + ->assertSchemaStateSet([ + 'slug' => Str::slug($title), + ]); +}); +``` + +If you have multiple schemas on a Livewire component, you can specify which schema you want to check using `assertSchemaStateSet([...], 'createPostForm')`. + +You may also find it useful to pass a function to the `assertSchemaStateSet()` method, which allows you to access the form `$state` and perform additional assertions: + +```php +use Illuminate\Support\Str; +use function Pest\Livewire\livewire; + +it('can automatically generate a slug from the title without any spaces', function () { + $title = fake()->sentence(); + + livewire(CreatePost::class) + ->fillForm([ + 'title' => $title, + ]) + ->assertSchemaStateSet(function (array $state): array { + expect($state['slug']) + ->not->toContain(' '); + + return [ + 'slug' => Str::slug($title), + ]; + }); +}); +``` + +You can return an array from the function if you want Filament to continue to assert the schema state after the function has been run. + +## Testing Form Validation + +Use `assertHasFormErrors()` to ensure that data is properly validated in a form: + +```php +use function Pest\Livewire\livewire; + +it('can validate input', function () { + livewire(CreatePost::class) + ->fillForm([ + 'title' => null, + ]) + ->call('create') + ->assertHasFormErrors(['title' => 'required']); +}); +``` + +And `assertHasNoFormErrors()` to ensure there are no validation errors: + +```php +use function Pest\Livewire\livewire; + +livewire(CreatePost::class) + ->fillForm([ + 'title' => fake()->sentence(), + // ... + ]) + ->call('create') + ->assertHasNoFormErrors(); +``` + +If you have multiple schemas on a Livewire component, you can pass the name of a specific form as the second parameter like `assertHasFormErrors(['title' => 'required'], 'createPostForm')` or `assertHasNoFormErrors([], 'createPostForm')`. + +## Testing the Existence of a Form + +To check that a Livewire component has a form, use `assertSchemaExists('form')`: + +```php +use function Pest\Livewire\livewire; + +it('has a form', function () { + livewire(CreatePost::class) + ->assertSchemaExists('form'); +}); +``` + +If you have multiple schemas on a Livewire component, you can pass the name of a specific form like `assertSchemaExists('createPostForm')`. + +## Testing the Existence of Form Fields + +To ensure that a form has a given field, pass the field name to `assertFormFieldExists()`: + +```php +use function Pest\Livewire\livewire; + +it('has a title field', function () { + livewire(CreatePost::class) + ->assertFormFieldExists('title'); +}); +``` + +You may pass a function as an additional argument to assert that a field passes a given "truth test". This is useful for asserting that a field has a specific configuration: + +```php +use function Pest\Livewire\livewire; + +it('has a title field', function () { + livewire(CreatePost::class) + ->assertFormFieldExists('title', function (TextInput $field): bool { + return $field->isDisabled(); + }); +}); +``` + +To assert that a form does not have a given field, pass the field name to `assertFormFieldDoesNotExist()`: + +```php +use function Pest\Livewire\livewire; + +it('does not have a conditional field', function () { + livewire(CreatePost::class) + ->assertFormFieldDoesNotExist('no-such-field'); +}); +``` + +If you have multiple schemas on a Livewire component, you can specify which form you want to check for the existence of the field like `assertFormFieldExists('title', 'createPostForm')`. + +## Testing the Visibility of Form Fields + +To ensure that a field is visible, pass the name to `assertFormFieldVisible()`: + +```php +use function Pest\Livewire\livewire; + +test('title is visible', function () { + livewire(CreatePost::class) + ->assertFormFieldVisible('title'); +}); +``` + +Or to ensure that a field is hidden you can pass the name to `assertFormFieldHidden()`: + +```php +use function Pest\Livewire\livewire; + +test('title is hidden', function () { + livewire(CreatePost::class) + ->assertFormFieldHidden('title'); +}); +``` + +For both `assertFormFieldHidden()` and `assertFormFieldVisible()` you can pass the name of a specific form the field belongs to as the second argument like `assertFormFieldHidden('title', 'createPostForm')`. + +## Testing Disabled Form Fields + +To ensure that a field is enabled, pass the name to `assertFormFieldEnabled()`: + +```php +use function Pest\Livewire\livewire; + +test('title is enabled', function () { + livewire(CreatePost::class) + ->assertFormFieldEnabled('title'); +}); +``` + +Or to ensure that a field is disabled you can pass the name to `assertFormFieldDisabled()`: + +```php +use function Pest\Livewire\livewire; + +test('title is disabled', function () { + livewire(CreatePost::class) + ->assertFormFieldDisabled('title'); +}); +``` + +For both `assertFormFieldEnabled()` and `assertFormFieldDisabled()` you can pass the name of a specific form the field belongs to as the second argument like `assertFormFieldEnabled('title', 'createPostForm')`. + +## Testing Other Schema Components + +If you need to check if a particular schema component exists rather than a field, you may use `assertSchemaComponentExists()`. As components do not have names, this method uses the `key()` provided by the developer: + +```php +use Filament\Schemas\Components\Section; + +Section::make('Comments') + ->key('comments-section') + ->schema([ + // + ]) +``` + +```php +use function Pest\Livewire\livewire; + +test('comments section exists', function () { + livewire(EditPost::class) + ->assertSchemaComponentExists('comments-section'); +}); +``` + +To assert that a schema does not have a given component, pass the component key to `assertSchemaComponentDoesNotExist()`: + +```php +use function Pest\Livewire\livewire; + +it('does not have a conditional component', function () { + livewire(CreatePost::class) + ->assertSchemaComponentDoesNotExist('no-such-section'); +}); +``` + +To check if the component exists and passes a given truth test, you can pass a function to the `checkComponentUsing` argument of `assertSchemaComponentExists()`, returning true or false if the component passes the test or not: + +```php +use Filament\Schemas\Components\Section; + +use function Pest\Livewire\livewire; + +test('comments section has heading', function () { + livewire(EditPost::class) + ->assertSchemaComponentExists( + 'comments-section', + checkComponentUsing: function (Section $component): bool { + return $component->getHeading() === 'Comments'; + }, + ); +}); +``` + +If you want more informative test results, you can embed an assertion within your truth test callback: + +```php +use Filament\Schemas\Components\Section; +use Illuminate\Testing\Assert; + +use function Pest\Livewire\livewire; + +test('comments section is enabled', function () { + livewire(EditPost::class) + ->assertSchemaComponentExists( + 'comments-section', + checkComponentUsing: function (Section $component): bool { + Assert::assertTrue( + $component->isEnabled(), + 'Failed asserting that comments-section is enabled.', + ); + + return true; + }, + ); +}); +``` + +## Testing the Visibility of Schema Components + +To ensure that a schema component is visible, pass the key to `assertSchemaComponentVisible()`: + +```php +use function Pest\Livewire\livewire; + +test('comments section is visible', function () { + livewire(EditPost::class) + ->assertSchemaComponentVisible('comments-section'); +}); +``` + +Or to ensure that a schema component is hidden you can pass the key to `assertSchemaComponentHidden()`: + +```php +use function Pest\Livewire\livewire; + +test('comments section is hidden', function () { + livewire(EditPost::class) + ->assertSchemaComponentHidden('comments-section'); +}); +``` + +For both `assertSchemaComponentHidden()` and `assertSchemaComponentVisible()` you can pass the name of a specific schema the component belongs to as the second argument like `assertSchemaComponentHidden('comments-section', 'createPostForm')`. + +## Testing Repeaters + +Internally, repeaters generate UUIDs for items to keep track of them in the Livewire HTML easier. This means that when you are testing a form with a repeater, you need to ensure that the UUIDs are consistent between the form and the test. This can be tricky, and if you don't do it correctly, your tests can fail as the tests are expecting a UUID, not a numeric key. + +However, since Livewire doesn't need to keep track of the UUIDs in a test, you can disable the UUID generation and replace them with numeric keys, using the `Repeater::fake()` method at the start of your test: + +```php +use Filament\Forms\Components\Repeater; +use function Pest\Livewire\livewire; + +$undoRepeaterFake = Repeater::fake(); + +livewire(EditPost::class, ['record' => $post]) + ->assertSchemaStateSet([ + 'quotes' => [ + [ + 'content' => 'First quote', + ], + [ + 'content' => 'Second quote', + ], + ], + // ... + ]); + +$undoRepeaterFake(); +``` + +You may also find it useful to test the number of items in a repeater by passing a function to the `assertSchemaStateSet()` method: + +```php +use Filament\Forms\Components\Repeater; +use function Pest\Livewire\livewire; + +$undoRepeaterFake = Repeater::fake(); + +livewire(EditPost::class, ['record' => $post]) + ->assertSchemaStateSet(function (array $state) { + expect($state['quotes']) + ->toHaveCount(2); + }); + +$undoRepeaterFake(); +``` + +## Testing Repeater Actions + +In order to test that repeater actions are working as expected, you can utilize the `callFormComponentAction()` method to call your repeater actions and then perform additional assertions. + +To interact with an action on a particular repeater item, you need to pass in the `item` argument with the key of that repeater item. If your repeater is reading from a relationship, you should prefix the ID (key) of the related record with `record-` to form the key of the repeater item: + +```php +use App\Models\Quote; +use Filament\Forms\Components\Repeater; +use function Pest\Livewire\livewire; + +$quote = Quote::first(); + +livewire(EditPost::class, ['record' => $post]) + ->callAction(TestAction::make('sendQuote')->schemaComponent('quotes')->arguments([ + 'item' => "record-{$quote->getKey()}", + ])) + ->assertNotified('Quote sent!'); +``` + +## Testing Builders + +Internally, builders generate UUIDs for items to keep track of them in the Livewire HTML easier. This means that when you are testing a form with a builder, you need to ensure that the UUIDs are consistent between the form and the test. This can be tricky, and if you don't do it correctly, your tests can fail as the tests are expecting a UUID, not a numeric key. + +However, since Livewire doesn't need to keep track of the UUIDs in a test, you can disable the UUID generation and replace them with numeric keys, using the `Builder::fake()` method at the start of your test: + +```php +use Filament\Forms\Components\Builder; +use function Pest\Livewire\livewire; + +$undoBuilderFake = Builder::fake(); + +livewire(EditPost::class, ['record' => $post]) + ->assertSchemaStateSet([ + 'content' => [ + [ + 'type' => 'heading', + 'data' => [ + 'content' => 'Hello, world!', + 'level' => 'h1', + ], + ], + [ + 'type' => 'paragraph', + 'data' => [ + 'content' => 'This is a test post.', + ], + ], + ], + // ... + ]); + +$undoBuilderFake(); +``` + +You may also find it useful to access test the number of items in a repeater by passing a function to the `assertSchemaStateSet()` method: + +```php +use Filament\Forms\Components\Builder; +use function Pest\Livewire\livewire; + +$undoBuilderFake = Builder::fake(); + +livewire(EditPost::class, ['record' => $post]) + ->assertSchemaStateSet(function (array $state) { + expect($state['content']) + ->toHaveCount(2); + }); + +$undoBuilderFake(); +``` + +## Testing Wizards + +To go to a wizard's next step, use `goToNextWizardStep()`: + +```php +use function Pest\Livewire\livewire; + +it('moves to next wizard step', function () { + livewire(CreatePost::class) + ->goToNextWizardStep() + ->assertHasFormErrors(['title']); +}); +``` + +You can also go to the previous step by calling `goToPreviousWizardStep()`: + +```php +use function Pest\Livewire\livewire; + +it('moves to next wizard step', function () { + livewire(CreatePost::class) + ->goToPreviousWizardStep() + ->assertHasFormErrors(['title']); +}); +``` + +If you want to go to a specific step, use `goToWizardStep()`, then the `assertWizardCurrentStep` method which can ensure you are on the desired step without validation errors from the previous: + +```php +use function Pest\Livewire\livewire; + +it('moves to the wizards second step', function () { + livewire(CreatePost::class) + ->goToWizardStep(2) + ->assertWizardCurrentStep(2); +}); +``` + +If you have multiple schemas on a single Livewire component, any of the wizard test helpers can accept a schema parameter: + +```php +use function Pest\Livewire\livewire; + +it('moves to next wizard step only for fooForm', function () { + livewire(CreatePost::class) + ->goToNextWizardStep(schema: 'fooForm') + ->assertHasFormErrors(['title'], schema: 'fooForm'); +}); +``` diff --git a/docs/testing-tables.md b/docs/testing-tables.md new file mode 100644 index 0000000..21f01f7 --- /dev/null +++ b/docs/testing-tables.md @@ -0,0 +1,477 @@ +# Testing Tables + +## Testing that a Table Can Render + +To ensure a table component renders, use the `assertSuccessful()` Livewire helper: + +```php +use function Pest\Livewire\livewire; + +it('can render page', function () { + livewire(ListPosts::class) + ->assertSuccessful(); +}); +``` + +To test which records are shown, you can use `assertCanSeeTableRecords()`, `assertCanNotSeeTableRecords()` and `assertCountTableRecords()`: + +```php +use function Pest\Livewire\livewire; + +it('cannot display trashed posts by default', function () { + $posts = Post::factory()->count(4)->create(); + $trashedPosts = Post::factory()->trashed()->count(6)->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertCanSeeTableRecords($posts) + ->assertCanNotSeeTableRecords($trashedPosts) + ->assertCountTableRecords(4); +}); +``` + +If your table uses pagination, `assertCanSeeTableRecords()` will only check for records on the first page. To switch page, call `call('gotoPage', 2)`. + +If your table uses `deferLoading()`, you should call `loadTable()` before `assertCanSeeTableRecords()`. + +## Testing Columns + +To ensure that a certain column is rendered, pass the column name to `assertCanRenderTableColumn()`: + +```php +use function Pest\Livewire\livewire; + +it('can render post titles', function () { + Post::factory()->count(10)->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertCanRenderTableColumn('title'); +}); +``` + +This helper will get the HTML for this column, and check that it is present in the table. + +For testing that a column is not rendered, you can use `assertCanNotRenderTableColumn()`: + +```php +use function Pest\Livewire\livewire; + +it('can not render post comments', function () { + Post::factory()->count(10)->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertCanNotRenderTableColumn('comments'); +}); +``` + +This helper will assert that the HTML for this column is not shown by default in the present table. + +## Testing that a Column Can Be Searched + +To search the table, call the `searchTable()` method with your search query. + +You can then use `assertCanSeeTableRecords()` to check your filtered table records, and use `assertCanNotSeeTableRecords()` to assert that some records are no longer in the table: + +```php +use function Pest\Livewire\livewire; + +it('can search posts by title', function () { + $posts = Post::factory()->count(10)->create(); + + $title = $posts->first()->title; + + livewire(PostResource\Pages\ListPosts::class) + ->searchTable($title) + ->assertCanSeeTableRecords($posts->where('title', $title)) + ->assertCanNotSeeTableRecords($posts->where('title', '!=', $title)); +}); +``` + +To search individual columns, you can pass an array of searches to `searchTableColumns()`: + +```php +use function Pest\Livewire\livewire; + +it('can search posts by title column', function () { + $posts = Post::factory()->count(10)->create(); + + $title = $posts->first()->title; + + livewire(PostResource\Pages\ListPosts::class) + ->searchTableColumns(['title' => $title]) + ->assertCanSeeTableRecords($posts->where('title', $title)) + ->assertCanNotSeeTableRecords($posts->where('title', '!=', $title)); +}); +``` + +## Testing that a Column Can Be Sorted + +To sort table records, you can call `sortTable()`, passing the name of the column to sort by. You can use `'desc'` in the second parameter of `sortTable()` to reverse the sorting direction. + +Once the table is sorted, you can ensure that the table records are rendered in order using `assertCanSeeTableRecords()` with the `inOrder` parameter: + +```php +use function Pest\Livewire\livewire; + +it('can sort posts by title', function () { + Post::factory()->count(10)->create(); + + $sortedPostsAsc = Post::query()->orderBy('title')->get(); + $sortedPostsDesc = Post::query()->orderBy('title', 'desc')->get(); + + livewire(PostResource\Pages\ListPosts::class) + ->sortTable('title') + ->assertCanSeeTableRecords($sortedPostsAsc, inOrder: true) + ->sortTable('title', 'desc') + ->assertCanSeeTableRecords($sortedPostsDesc, inOrder: true); +}); +``` + +Filament tables use a SQL order statement to sort records before they are output. Different database drivers can use different sorting strategies, and they can differ from PHP's own sorting strategy, so you should ensure that test records are sorted using `orderBy()` on a database query rather than `sortBy()` on a collection of models. + +## Testing the State of a Column + +To assert that a certain column has a state or does not have a state for a record you can use `assertTableColumnStateSet()` and `assertTableColumnStateNotSet()`: + +```php +use function Pest\Livewire\livewire; + +it('can get post author names', function () { + $posts = Post::factory()->count(10)->create(); + + $post = $posts->first(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertTableColumnStateSet('author.name', $post->author->name, record: $post) + ->assertTableColumnStateNotSet('author.name', 'Anonymous', record: $post); +}); +``` + +To assert that a certain column has a formatted state or does not have a formatted state for a record you can use `assertTableColumnFormattedStateSet()` and `assertTableColumnFormattedStateNotSet()`: + +```php +use function Pest\Livewire\livewire; + +it('can get post author names', function () { + $post = Post::factory(['name' => 'John Smith'])->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertTableColumnFormattedStateSet('author.name', 'Smith, John', record: $post) + ->assertTableColumnFormattedStateNotSet('author.name', $post->author->name, record: $post); +}); +``` + +## Testing the Existence of a Column + +To ensure that a column exists, you can use the `assertTableColumnExists()` method: + +```php +use function Pest\Livewire\livewire; + +it('has an author column', function () { + livewire(PostResource\Pages\ListPosts::class) + ->assertTableColumnExists('author'); +}); +``` + +You may pass a function as an additional argument to assert that a column passes a given "truth test". This is useful for asserting that a column has a specific configuration. You can also pass in a record as the third parameter, which is useful if your check is dependent on which table row is being rendered: + +```php +use function Pest\Livewire\livewire; +use Filament\Tables\Columns\TextColumn; + +it('has an author column', function () { + $post = Post::factory()->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertTableColumnExists('author', function (TextColumn $column): bool { + return $column->getDescriptionBelow() === $post->subtitle; + }, $post); +}); +``` + +## Testing the Visibility of a Column + +To ensure that a particular user cannot see a column, you can use the `assertTableColumnVisible()` and `assertTableColumnHidden()` methods: + +```php +use function Pest\Livewire\livewire; + +it('shows the correct columns', function () { + livewire(PostResource\Pages\ListPosts::class) + ->assertTableColumnVisible('created_at') + ->assertTableColumnHidden('author'); +}); +``` + +## Testing the Description of a Column + +To ensure a column has the correct description above or below you can use the `assertTableColumnHasDescription()` and `assertTableColumnDoesNotHaveDescription()` methods: + +```php +use function Pest\Livewire\livewire; + +it('has the correct descriptions above and below author', function () { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->assertTableColumnHasDescription('author', 'Author! ↓↓↓', $post, 'above') + ->assertTableColumnHasDescription('author', 'Author! ↑↑↑', $post) + ->assertTableColumnDoesNotHaveDescription('author', 'Author! ↑↑↑', $post, 'above') + ->assertTableColumnDoesNotHaveDescription('author', 'Author! ↓↓↓', $post); +}); +``` + +## Testing the Extra Attributes of a Column + +To ensure that a column has the correct extra attributes, you can use the `assertTableColumnHasExtraAttributes()` and `assertTableColumnDoesNotHaveExtraAttributes()` methods: + +```php +use function Pest\Livewire\livewire; + +it('displays author in red', function () { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->assertTableColumnHasExtraAttributes('author', ['class' => 'text-danger-500'], $post) + ->assertTableColumnDoesNotHaveExtraAttributes('author', ['class' => 'text-primary-500'], $post); +}); +``` + +## Testing the Options in a SelectColumn + +If you have a select column, you can ensure it has the correct options with `assertTableSelectColumnHasOptions()` and `assertTableSelectColumnDoesNotHaveOptions()`: + +```php +use function Pest\Livewire\livewire; + +it('has the correct statuses', function () { + $post = Post::factory()->create(); + + livewire(PostsTable::class) + ->assertTableSelectColumnHasOptions('status', ['unpublished' => 'Unpublished', 'published' => 'Published'], $post) + ->assertTableSelectColumnDoesNotHaveOptions('status', ['archived' => 'Archived'], $post); +}); +``` + +## Testing Filters + +To filter the table records, you can use the `filterTable()` method, along with `assertCanSeeTableRecords()` and `assertCanNotSeeTableRecords()`: + +```php +use function Pest\Livewire\livewire; + +it('can filter posts by `is_published`', function () { + $posts = Post::factory()->count(10)->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertCanSeeTableRecords($posts) + ->filterTable('is_published') + ->assertCanSeeTableRecords($posts->where('is_published', true)) + ->assertCanNotSeeTableRecords($posts->where('is_published', false)); +}); +``` + +For a simple filter, this will just enable the filter. + +If you'd like to set the value of a `SelectFilter` or `TernaryFilter`, pass the value as a second argument: + +```php +use function Pest\Livewire\livewire; + +it('can filter posts by `author_id`', function () { + $posts = Post::factory()->count(10)->create(); + + $authorId = $posts->first()->author_id; + + livewire(PostResource\Pages\ListPosts::class) + ->assertCanSeeTableRecords($posts) + ->filterTable('author_id', $authorId) + ->assertCanSeeTableRecords($posts->where('author_id', $authorId)) + ->assertCanNotSeeTableRecords($posts->where('author_id', '!=', $authorId)); +}); +``` + +## Resetting Filters in a Test + +To reset all filters to their original state, call `resetTableFilters()`: + +```php +use function Pest\Livewire\livewire; + +it('can reset table filters', function () { + $posts = Post::factory()->count(10)->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->resetTableFilters(); +}); +``` + +## Removing Filters in a Test + +To remove a single filter you can use `removeTableFilter()`: + +```php +use function Pest\Livewire\livewire; + +it('filters list by published', function () { + $posts = Post::factory()->count(10)->create(); + + $unpublishedPosts = $posts->where('is_published', false)->get(); + + livewire(PostsTable::class) + ->filterTable('is_published') + ->assertCanNotSeeTableRecords($unpublishedPosts) + ->removeTableFilter('is_published') + ->assertCanSeeTableRecords($posts); +}); +``` + +To remove all filters you can use `removeTableFilters()`: + +```php +use function Pest\Livewire\livewire; + +it('can remove all table filters', function () { + $posts = Post::factory()->count(10)->forAuthor()->create(); + + $unpublishedPosts = $posts + ->where('is_published', false) + ->where('author_id', $posts->first()->author->getKey()); + + livewire(PostsTable::class) + ->filterTable('is_published') + ->filterTable('author', $author) + ->assertCanNotSeeTableRecords($unpublishedPosts) + ->removeTableFilters() + ->assertCanSeeTableRecords($posts); +}); +``` + +## Testing the Visibility of a Filter + +To ensure that a particular user cannot see a filter, you can use the `assertTableFilterVisible()` and `assertTableFilterHidden()` methods: + +```php +use function Pest\Livewire\livewire; + +it('shows the correct filters', function () { + livewire(PostsTable::class) + ->assertTableFilterVisible('created_at') + ->assertTableFilterHidden('author'); +}); +``` + +## Testing the Existence of a Filter + +To ensure that a filter exists, you can use the `assertTableFilterExists()` method: + +```php +use function Pest\Livewire\livewire; + +it('has an author filter', function () { + livewire(PostResource\Pages\ListPosts::class) + ->assertTableFilterExists('author'); +}); +``` + +You may pass a function as an additional argument to assert that a filter passes a given "truth test". This is useful for asserting that a filter has a specific configuration: + +```php +use function Pest\Livewire\livewire; +use Filament\Tables\Filters\SelectFilter; + +it('has an author filter', function () { + livewire(PostResource\Pages\ListPosts::class) + ->assertTableFilterExists('author', function (SelectFilter $column): bool { + return $column->getLabel() === 'Select author'; + }); +}); +``` + +## Testing Summaries + +To test that a summary calculation is working, you may use the `assertTableColumnSummarySet()` method: + +```php +use function Pest\Livewire\livewire; + +it('can average values in a column', function () { + $posts = Post::factory()->count(10)->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertCanSeeTableRecords($posts) + ->assertTableColumnSummarySet('rating', 'average', $posts->avg('rating')); +}); +``` + +The first argument is the column name, the second is the summarizer ID, and the third is the expected value. + +Note that the expected and actual values are normalized, such that 123.12 is considered the same as "123.12", and ['Fred', 'Jim'] is the same as ['Jim', 'Fred']. + +You may set a summarizer ID by passing it to the `make()` method: + +```php +use Filament\Tables\Columns\Summarizers\Average; +use Filament\Tables\Columns\TextColumn; + +TextColumn::make('rating') + ->summarize(Average::make('average')); +``` + +The ID should be unique between summarizers in that column. + +## Testing Summaries on Only One Pagination Page + +To calculate the average for only one pagination page, use the `isCurrentPaginationPageOnly` argument: + +```php +use function Pest\Livewire\livewire; + +it('can average values in a column', function () { + $posts = Post::factory()->count(20)->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertCanSeeTableRecords($posts->take(10)) + ->assertTableColumnSummarySet('rating', 'average', $posts->take(10)->avg('rating'), isCurrentPaginationPageOnly: true); +}); +``` + +## Testing a Range Summarizer + +To test a range, pass the minimum and maximum value into a tuple-style [$minimum, $maximum] array: + +```php +use function Pest\Livewire\livewire; + +it('can average values in a column', function () { + $posts = Post::factory()->count(10)->create(); + + livewire(PostResource\Pages\ListPosts::class) + ->assertCanSeeTableRecords($posts) + ->assertTableColumnSummarySet('rating', 'range', [$posts->min('rating'), $posts->max('rating')]); +}); +``` + +## Testing Toggleable Columns + +By default, only columns that are toggled on by default in the table will be rendered and testable. You can toggle all columns in the table on using `toggleAllTableColumns()`: + +```php +use function Pest\Livewire\livewire; + +it('can toggle all columns', function () { + livewire(PostResource\Pages\ListPosts::class) + ->toggleAllTableColumns(); +}); +``` + +You can also toggle all columns off using `toggleAllTableColumns(false)`: + +```php +use function Pest\Livewire\livewire; + +it('can toggle all columns off', function () { + livewire(PostResource\Pages\ListPosts::class) + ->toggleAllTableColumns(false); +}); +```