Search

Testing Filament Panels with Pest

Testing Filament Panels with Pest

Key takeaways

  • ✓ Filament pages, relation managers and widgets are Livewire components. Test them with livewire(...) from pest-plugin-livewire, plus Filament’s assertion helpers.
  • ✓ A small suite catches most regressions: page loads, create/edit happy paths, validation datasets, key actions, and permission checks.
  • ✓ Target actions with TestAction::make(...), adding ->table($record) for row actions and ->table()->bulk() for bulk actions.
  • ✓ In multi-panel or multi-tenant apps, set the panel (Filament::setCurrentPanel()) and tenant (Filament::setTenant() plus bootCurrentPanel()) in the test.

Admin panels quietly accumulate business logic: refunds, approvals, permission rules, imports. They’re also where a broken form isn’t noticed until finance calls. The good news is that Filament panels are very testable. Every page is a Livewire component, and Filament adds assertion helpers for forms, tables, actions and notifications. This guide builds a practical Pest suite for a Filament v5 resource.

Also Read: 25 Best Filament Plugins in 2026 (Free & Paid, v4/v5 Tested)

Setup

Pest and the Livewire plugin:

composer require pestphp/pest pestphp/pest-plugin-laravel pestphp/pest-plugin-livewire --dev -W
./vendor/bin/pest --init

In tests/Pest.php, use a fresh database for feature tests:

pest()->extend(Tests\TestCase::class)
    ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

Authenticate at the top of each Filament test file:

use App\Models\User;
use function Pest\Laravel\actingAs;

beforeEach(function () {
    actingAs(User::factory()->admin()->create());
});

Make sure the user can pass canAccessPanel(). If the factory user can’t, every test fails with a redirect, which is confusing the first time you see it.

Multiple panels? Filament normally sets the current panel in middleware. Livewire component tests skip HTTP middleware, so set the panel yourself:

use Filament\Facades\Filament;

beforeEach(fn () => Filament::setCurrentPanel('admin'));

Testing the list page

use App\Filament\Resources\Customers\Pages\ListCustomers;
use App\Models\Customer;
use function Pest\Livewire\livewire;

it('lists customers', function () {
    $customers = Customer::factory()->count(5)->create();

    livewire(ListCustomers::class)
        ->assertOk()
        ->assertCanSeeTableRecords($customers);
});

it('searches customers by name or email', function () {
    $customers = Customer::factory()->count(5)->create();
    $target = $customers->first();

    livewire(ListCustomers::class)
        ->searchTable($target->email)
        ->assertCanSeeTableRecords([$target])
        ->assertCanNotSeeTableRecords($customers->skip(1));
});

it('sorts by name', function () {
    $customers = Customer::factory()->count(5)->create();

    livewire(ListCustomers::class)
        ->sortTable('name')
        ->assertCanSeeTableRecords($customers->sortBy('name'), inOrder: true)
        ->sortTable('name', 'desc')
        ->assertCanSeeTableRecords($customers->sortByDesc('name'), inOrder: true);
});

it('filters by status', function () {
    $active = Customer::factory()->active()->count(2)->create();
    $churned = Customer::factory()->churned()->count(2)->create();

    livewire(ListCustomers::class)
        ->filterTable('status', 'active')
        ->assertCanSeeTableRecords($active)
        ->assertCanNotSeeTableRecords($churned);
});

These four tests alone catch broken columns, a relationship column that errors, a searchable() pointing at a column that doesn’t exist, and filter regressions after upgrades.

Also Read: Filament vs Building Your Own Admin Panel: When Is It Worth It?

Testing the create page (with a validation dataset)

use App\Filament\Resources\Customers\Pages\CreateCustomer;
use Illuminate\Support\Str;
use function Pest\Laravel\assertDatabaseHas;

it('creates a customer', function () {
    $data = Customer::factory()->make();

    livewire(CreateCustomer::class)
        ->fillForm([
            'name' => $data->name,
            'email' => $data->email,
            'plan' => 'pro',
        ])
        ->call('create')
        ->assertHasNoFormErrors()
        ->assertNotified()
        ->assertRedirect();

    assertDatabaseHas(Customer::class, ['email' => $data->email, 'plan' => 'pro']);
});

it('validates input', function (array $input, array $errors) {
    livewire(CreateCustomer::class)
        ->fillForm([
            'name' => 'Jane Doe',
            'email' => '[email protected]',
            'plan' => 'pro',
            ...$input,
        ])
        ->call('create')
        ->assertHasFormErrors($errors)
        ->assertNotNotified()
        ->assertNoRedirect();
})->with([
    'name is required' => [['name' => null], ['name' => 'required']],
    'name max 255' => [['name' => Str::random(256)], ['name' => 'max']],
    'email must be valid' => [['email' => 'nope'], ['email' => 'email']],
    'email must be unique' => [fn () => ['email' => Customer::factory()->create()->email], ['email' => 'unique']],
]);

The Pest dataset is the key pattern. Each validation rule gets a named case, and adding a rule means adding one line.

Testing the edit and view pages

use App\Filament\Resources\Customers\Pages\EditCustomer;
use App\Filament\Resources\Customers\Pages\ViewCustomer;
use Filament\Actions\DeleteAction;
use function Pest\Laravel\assertModelMissing;

it('loads the edit form with the record', function () {
    $customer = Customer::factory()->create();

    livewire(EditCustomer::class, ['record' => $customer->getRouteKey()])
        ->assertOk()
        ->assertSchemaStateSet([
            'name' => $customer->name,
            'email' => $customer->email,
        ]);
});

it('updates a customer', function () {
    $customer = Customer::factory()->create();

    livewire(EditCustomer::class, ['record' => $customer->getRouteKey()])
        ->fillForm(['name' => 'Updated Ltd'])
        ->call('save')
        ->assertHasNoFormErrors()
        ->assertNotified();

    expect($customer->fresh()->name)->toBe('Updated Ltd');
});

it('deletes a customer from the edit page', function () {
    $customer = Customer::factory()->create();

    livewire(EditCustomer::class, ['record' => $customer->getRouteKey()])
        ->callAction(DeleteAction::class)
        ->assertNotified()
        ->assertRedirect();

    assertModelMissing($customer);
});

it('shows the view page', function () {
    $customer = Customer::factory()->create();

    livewire(ViewCustomer::class, ['record' => $customer->getRouteKey()])
        ->assertOk()
        ->assertSchemaStateSet(['email' => $customer->email]);
});

Testing custom actions

Since v4, TestAction says precisely which action you mean and where it lives:

use Filament\Actions\DeleteBulkAction;
use Filament\Actions\Testing\TestAction;

it('refunds an order with a reason', function () {
    $order = Order::factory()->paid()->create(['total' => 120]);

    livewire(ListOrders::class)
        ->callAction(TestAction::make('refund')->table($order), data: [
            'amount' => 20,
            'reason' => 'damaged',
        ])
        ->assertHasNoFormErrors()
        ->assertNotified('Refund issued');

    expect($order->fresh()->refunded_amount)->toEqual(20);
});

it('requires a reason to refund', function () {
    $order = Order::factory()->paid()->create();

    livewire(ListOrders::class)
        ->callAction(TestAction::make('refund')->table($order), data: ['amount' => 5, 'reason' => null])
        ->assertHasFormErrors(['reason' => 'required']);
});

it('bulk deletes orders', function () {
    $orders = Order::factory()->count(3)->create();

    livewire(ListOrders::class)
        ->selectTableRecords($orders)
        ->callAction(TestAction::make(DeleteBulkAction::class)->table()->bulk())
        ->assertNotified();

    $orders->each(fn ($order) => expect($order->fresh())->toBeNull());
});

Other useful assertions: assertActionVisible(), assertActionHidden(), assertActionEnabled(), assertActionDisabled(), mountAction() followed by assertSchemaStateSet() to check pre-filled modal forms, and assertActionHalted(). See the testing actions docs. The patterns in our actions and bulk actions guide map directly onto these.

Also Read: Laravel: Best Filament Themes

Testing permissions

Hidden buttons aren’t enough. Test the policy and the UI:

use function Pest\Laravel\get;

it('forbids support staff from the users resource', function () {
    actingAs(User::factory()->withRole('support')->create());

    get(UserResource::getUrl('index'))->assertForbidden();
});

it('hides refund for users without permission', function () {
    actingAs(User::factory()->withRole('viewer')->create());
    $order = Order::factory()->paid()->create();

    livewire(ListOrders::class)
        ->assertActionHidden(TestAction::make('refund')->table($order));
});

The HTTP test goes through middleware and the panel’s real authorisation. The Livewire test checks the UI state. See roles and permissions in Filament for the policy side.

Relation managers

it('creates a note on a customer', function () {
    $customer = Customer::factory()->create();

    livewire(NotesRelationManager::class, [
        'ownerRecord' => $customer,
        'pageClass' => EditCustomer::class,
    ])
        ->callAction(TestAction::make(CreateAction::class)->table(), data: ['body' => 'Called about renewal'])
        ->assertHasNoFormErrors();

    expect($customer->notes()->count())->toBe(1);
});

Multi-tenant panels

beforeEach(function () {
    $this->team = Team::factory()->create();
    $user = User::factory()->create();
    $user->teams()->attach($this->team);
    actingAs($user);

    Filament::setCurrentPanel('app');
    Filament::setTenant($this->team);
    Filament::bootCurrentPanel();   // applies tenant scopes and model listeners
});

it('isolates tenants', function () {
    $mine = Customer::factory()->for($this->team)->create();
    $theirs = Customer::factory()->for(Team::factory())->create();

    livewire(ListCustomers::class)
        ->assertCanSeeTableRecords([$mine])
        ->assertCanNotSeeTableRecords([$theirs]);
});

Write one isolation test per tenant-owned resource. It’s the most valuable test in any multi-tenant Filament app.

Also Read: Laravel: Filament Blueprint Review

Widgets and custom pages

it('shows today\'s revenue', function () {
    Order::factory()->create(['total' => 250, 'created_at' => now()]);

    livewire(RevenueStats::class)->assertSee('£250.00');
});

For behaviour that depends on JavaScript (Alpine interactions, Livewire 4 islands, drag-and-drop), use Pest’s browser testing (Pest 4+) for a few critical flows rather than for everything.

Custom pages, infolists and notifications

Custom pages are Livewire components like everything else:

it('loads the reports page for finance users', function () {
    actingAs(User::factory()->withRole('finance')->create());

    livewire(\App\Filament\Pages\Reports::class)
        ->assertOk()
        ->assertSee('Revenue by region');
});

For notifications, assertNotified() with no arguments checks that a notification was sent. Pass a title string (assertNotified('Refund issued')) or a full Notification object when the wording matters, for example because support documentation refers to it. For database notifications sent from queued jobs, such as import completion, assert against the notifications table or use Notification::fake() from Laravel.

Also Read: Claude Code & Cursor on Filament: AI Agent Rules That Work - Laravel

What not to test

A Filament suite gets slow and brittle when it tests the framework instead of your app. Skip:

  • Filament’s own behaviour. You don’t need to prove that ->sortable() sorts on a plain column. Test sorting only where you wrote a custom sortable(query: ...).
  • Exact markup. Assert on state and data (assertSchemaStateSet, assertCanSeeTableRecords), not on HTML snippets that change with every Filament release.
  • Every field’s label. Test labels only when they’re derived from business logic.

Focus on your rules: validation, policies, calculations in actions, tenant isolation, and custom queries.

Running it in CI

A minimal GitHub Actions job that runs the Filament suite on every pull request:

name: tests
on: [pull_request]
jobs:
  pest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none
      - run: composer install --no-interaction --prefer-dist
      - run: cp .env.example .env && php artisan key:generate
      - run: npm ci && npm run build   # custom theme assets, if any
      - run: php artisan test --parallel

If your panel uses a custom theme, building assets in CI also catches broken @source paths and Tailwind errors before they reach production.

Also Read: What Is Jev? TypeSafe AI's 'System One' Model Explained (Pricing, API & Use Cases)

Keeping the suite fast

  • Run in parallel: php artisan test --parallel.
  • Use SQLite in memory for speed, or your production database engine in CI if you rely on engine-specific SQL.
  • Prefer Model::factory()->make() for form data. Only create() what the test needs in the database.
  • Put shared beforeEach() setup in tests/Pest.php for each directory (->in('Feature/Filament')).

A starter checklist per resource

  1. List page loads and shows records.
  2. Search works on every searchable() column.
  3. Each filter narrows the results.
  4. Create: happy path plus a validation dataset.
  5. Edit: loads state, saves changes.
  6. Delete / restore / custom actions: effects plus notifications.
  7. Permissions: forbidden roles get 403, and hidden actions stay hidden.
  8. Tenancy: other tenants’ records are invisible.

FAQ

How do I test a Filament resource with Pest?

Install pestphp/pest-plugin-livewire, authenticate a user who can access the panel, then use livewire(ListRecords::class) with assertCanSeeTableRecords(), fillForm() + call('create'), assertHasFormErrors() and callAction().

How do I test a Filament table row action?

Use callAction(TestAction::make('actionName')->table($record), data: [...]). For bulk actions, select records with selectTableRecords() and call TestAction::make(...)->table()->bulk().

Why do my Filament tests redirect to login or return 403?

The test user fails canAccessPanel() or a policy. Also check that you’ve set the correct panel with Filament::setCurrentPanel() in multi-panel apps.

How do I test multi-tenant Filament resources?

Call Filament::setTenant($team) and Filament::bootCurrentPanel() after authenticating, so tenant scopes apply, then assert that other tenants’ records are hidden.

Sources and further reading

Related on The Web Tier: Filament v4 to v5 upgrade guide

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy