The first Filament test most people write is $this->get(PostResource::getUrl('index')), and it either redirects to login or passes against a page that rendered nothing useful. The problem is the entry point: Filament v5 resource pages are Livewire components, so an HTTP request tests the route, not the table. Point Pest's livewire() helper at the page class instead and you get Filament's own table, form and action assertions.
Authenticate a panel user in your Pest test#
Install the Pest Livewire plugin, then authenticate before every test. Filament's authorization runs on mount, so an unauthenticated test aborts before your assertions get a chance to run.
composer require pestphp/pest-plugin-livewire --dev
<?php
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Livewire\livewire;
beforeEach(function () {
actingAs(User::factory()->create());
});
If your app has more than one panel, tell Filament which one you are testing. Filament normally sets this in middleware, and Livewire test requests run without middleware:
use Filament\Facades\Filament;
Filament::setCurrentPanel('admin'); // The panel ID, not the class.
Everything below is a plain Livewire component test with extra assertions bolted on. If the livewire() call itself is unfamiliar, testing Livewire 4 components with Pest covers the underlying API.
Assert the list page renders the expected records#
Pass the List page class to livewire() and assert on the records the table resolved. assertCanSeeTableRecords() takes a collection and checks each record is present; its inverse proves the ones you excluded really are gone.
use App\Filament\Resources\Posts\Pages\ListPosts;
use App\Models\Post;
it('lists published posts and hides drafts', function () {
$published = Post::factory()->count(3)->published()->create();
$drafts = Post::factory()->count(2)->draft()->create();
livewire(ListPosts::class)
->assertOk()
->assertCanSeeTableRecords($published)
->assertCanNotSeeTableRecords($drafts)
->assertCountTableRecords(3);
});
Search, sort and filter are one method each, and they chain onto the same assertions:
livewire(ListPosts::class)
->searchTable($published->first()->title)
->assertCanSeeTableRecords($published->take(1))
->sortTable('title', 'desc')
->filterTable('status', 'published');
Tabs on a list page are just query scopes, so they are covered by the same assertCanSeeTableRecords() calls once the tab is active — see status tabs with counts on a Filament v5 list page for how those scopes are defined.
Fill and submit the create form#
fillForm() writes state into the page's schema, call('create') runs the same method the Create button does, and assertHasNoFormErrors() fails loudly if validation stopped the save. Assert on the database afterwards rather than trusting the notification alone.
use App\Filament\Resources\Posts\Pages\CreatePost;
use function Pest\Laravel\assertDatabaseHas;
it('creates a post', function () {
$newPost = Post::factory()->make();
livewire(CreatePost::class)
->fillForm([
'title' => $newPost->title,
'body' => $newPost->body,
])
->call('create')
->assertHasNoFormErrors()
->assertNotified()
->assertRedirect();
assertDatabaseHas(Post::class, ['title' => $newPost->title]);
});
Edit pages take the record as a mount parameter and save with call('save'). Use assertSchemaStateSet() to prove the form hydrated from the record — this is the v5 name for what used to be assertFormSet(), and it covers infolists on View pages too:
use App\Filament\Resources\Posts\Pages\EditPost;
it('hydrates and updates a post', function () {
$post = Post::factory()->create();
livewire(EditPost::class, ['record' => $post->getKey()])
->assertSchemaStateSet(['title' => $post->title])
->fillForm(['title' => 'Updated title'])
->call('save')
->assertHasNoFormErrors();
expect($post->refresh()->title)->toBe('Updated title');
});
Assert validation errors on bad input#
assertHasFormErrors() takes the same shape as Livewire's assertHasErrors(), keyed by field and rule. Pair it with assertNotNotified() and assertNoRedirect() so a broken save cannot pass by silently doing nothing.
use Illuminate\Support\Str;
it('validates the post form', function (array $data, array $errors) {
livewire(CreatePost::class)
->fillForm([...Post::factory()->make()->toArray(), ...$data])
->call('create')
->assertHasFormErrors($errors)
->assertNotNotified()
->assertNoRedirect();
})->with([
'`title` is required' => [['title' => null], ['title' => 'required']],
'`title` is max 255' => [['title' => Str::random(256)], ['title' => 'max']],
'`slug` is unique' => [['slug' => 'taken'], ['slug' => 'unique']],
]);
A Pest dataset is worth the extra line here. One failing rule names itself in the test output instead of collapsing five cases into a single opaque failure.
Call a table action and a bulk action#
This is the API that moved in v5. Table actions are addressed with a TestAction object passed to the generic callAction() — ->table($record) targets a row action, ->table() with no argument targets a header action, and ->bulk() targets a bulk action after you have selected records.
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\Testing\TestAction;
use function Pest\Laravel\assertDatabaseMissing;
it('publishes a post from the row action', function () {
$post = Post::factory()->draft()->create();
livewire(ListPosts::class)
->callAction(TestAction::make('publish')->table($post))
->assertNotified();
expect($post->refresh()->isPublished())->toBeTrue();
});
it('bulk deletes posts', function () {
$posts = Post::factory()->count(3)->create();
livewire(ListPosts::class)
->selectTableRecords($posts->pluck('id')->toArray())
->callAction(TestAction::make(DeleteBulkAction::class)->table()->bulk())
->assertCanNotSeeTableRecords($posts);
$posts->each(fn (Post $post) => assertDatabaseMissing($post));
});
If the action opens a modal with a form, pass its payload as the second argument and assert on validation exactly as you would on a page form:
livewire(ListPosts::class)
->callAction(TestAction::make('schedule')->table($post), data: [
'publish_at' => now()->addDay(),
])
->assertHasNoFormErrors();
The v3-era callTableAction() and callTableBulkAction() still exist in v5 as thin wrappers over callAction(), so old suites keep passing. New tests should use TestAction — it is the documented API and the only one that handles nested and schema-component actions. The Filament v4 to v5 upgrade guide covers the rest of the renames.
Assert an unauthorised user is blocked#
An unauthorised user should hit a 403, not a table that quietly renders zero rows. Filament enforces this inside the component: Filament\Resources\Pages\Page uses the CanAuthorizeResourceAccess trait, whose mountCanAuthorizeResourceAccess() hook runs abort_unless(static::getResource()::canAccess(), 403) on every mount and hydration, and canAccess() defers to your viewAny policy.
it('blocks a user who cannot view posts', function () {
actingAs(User::factory()->create()); // No `viewAny` permission.
livewire(ListPosts::class)->assertForbidden();
});
it('blocks a user who cannot create posts', function () {
actingAs(User::factory()->create());
livewire(CreatePost::class)->assertForbidden();
});
The finer-grained checks live on each page class: CreateRecord aborts on canCreate(), EditRecord on canEdit($record) and ViewRecord on canView($record). Note that ListRecords::authorizeAccess() is deliberately empty — the viewAny check comes from the trait hook, not from that method, so overriding it will not tighten a list page. If you are still writing those policies, Filament v5 authorization with policies, roles and permissions walks through all four layers.
Sidestep the gotchas that break Filament v5 tests#
Most red tests in a Filament suite come from the table resolving differently under test than it does in the browser. These are the ones worth knowing before you spend an afternoon on them.
- Deferred loading. If the table calls
deferLoading(), no records exist on first render. CallloadTable()beforeassertCanSeeTableRecords(). - Pagination.
assertCanSeeTableRecords()only inspects the current page. Create fewer records than your page size, or move withcall('gotoPage', 2). - Sorting. Assert against
Post::query()->orderBy('title')->get(), not$posts->sortBy('title'). Your database and PHP disagree about collation, and the database wins. - Toggleable columns. Columns toggled off by default are not rendered, so column assertions fail. Call
toggleAllTableColumns()first. - Relation managers. They are Livewire components too, but they need both parameters:
livewire(CommentsRelationManager::class, ['ownerRecord' => $post, 'pageClass' => EditPost::class]). - Custom action classes. Filament cannot discover a name from your own action class without running
make(). Add an#[ActionName('publish')]attribute, or agetDefaultName()method if it extendsAction, then pass the class name toTestAction::make(). - Multi-tenancy. Set the tenant, set the panel, then call
Filament::bootCurrentPanel()— without the boot call, tenant scopes never apply and your assertions pass against unscoped data.
Summary rows have their own assertion, assertTableColumnSummarySet('total', 'sum', $expected), which is easy to miss when you are checking column totals with table summarizers.
Run the Pest suite and wire it into CI#
Run the file on its own while you iterate, then let CI run the lot. A resource covered by the six tests above takes well under a second, which is the whole argument for testing Filament this way rather than through a browser.
php artisan test --compact --filter=PostResourceTest
composer test
Once the resource suite is green, apply the same pattern to custom pages and widgets — they are ordinary Livewire components, covered in custom pages and Livewire widgets in Filament v5. If the suite starts to drag on a large panel, sharding Pest across parallel CI jobs with GitHub Actions is the cheapest speed-up available.
FAQ#
How do I test a Filament resource?
Test each of its pages as a Livewire component rather than making HTTP requests to the resource URL. Pass the page class to Pest's livewire() function — livewire(ListPosts::class) — and chain Filament's assertions such as assertCanSeeTableRecords(), fillForm() and callAction(). Authenticate a user in a beforeEach() block first, because Filament checks authorization when the component mounts.
How do I test a Filament form with Pest?
Mount the Create or Edit page, populate the schema with fillForm(), then call the page method the submit button calls — call('create') on a Create page or call('save') on an Edit page. Chain assertHasNoFormErrors() to prove validation passed, and follow up with assertDatabaseHas() so you are testing the saved record and not just the absence of errors. Use assertSchemaStateSet() to check a form hydrated correctly from an existing record.
How do I test a Filament table action?
Build a TestAction from Filament\Actions\Testing\TestAction and pass it to callAction(). Use TestAction::make('publish')->table($record) for a row action, ->table() with no argument for a header action, and selectTableRecords() followed by TestAction::make(DeleteBulkAction::class)->table()->bulk() for a bulk action. If the action opens a modal form, pass the form payload as the data argument of callAction().
Why does my Filament test redirect to login?
Because the test is making a real HTTP request. $this->get(PostResource::getUrl('index')) passes through the panel's Authenticate middleware, which redirects guests to the login page before your resource is ever reached. Livewire component tests run without middleware, so livewire(ListPosts::class) never produces a 302 — an unauthorised user gets a 403 from Filament's own policy check instead. Switch to livewire() and authenticate with actingAs().
How do I assert a Filament form has validation errors?
Fill the form with the invalid data, call the submit method, then use assertHasFormErrors() with an array keyed by field name and rule, such as assertHasFormErrors(['title' => 'required']). Add assertNotNotified() and assertNoRedirect() so the test also proves the save was actually blocked. A Pest dataset is a tidy way to cover several rules without repeating the test body.