#09 FilamentPHP応用

テストの書き方——Pestでリソースをテストする

FilamentのテストはLivewireテストをベースにしている

FilamentはLivewireコンポーネントの上に構築されているため、テストもLivewire::test()(またはPestのlivewire()ヘルパー)を使って書きます。Filamentはこれにさらに専用のアサーションを追加しており、フォームのフィールド検証・テーブルの表示確認・アクションの実行テストが書きやすくなっています。

まず必要なパッケージを確認します。

composer require --dev pestphp/pest pestphp/pest-plugin-laravel
php artisan vendor:publish --tag=filament-tests

php artisan make:filament-test でテストスキャフォールド生成

php artisan make:filament-test PostResource

生成ファイル: tests/Feature/PostResourceTest.php

初期状態のテストファイルには一般的なテストケースのテンプレートが含まれます。

use App\Filament\Resources\PostResource\Pages\CreatePost;
use App\Filament\Resources\PostResource\Pages\EditPost;
use App\Filament\Resources\PostResource\Pages\ListPosts;
use App\Models\Post;
use App\Models\User;

use function Pest\Laravel\actingAs;
use function Pest\Livewire\livewire;

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

テーブルのレコード表示テスト

it('テーブルにレコードが表示される', function () {
    $posts = Post::factory()->count(5)->create();

    livewire(ListPosts::class)
        ->assertCanSeeTableRecords($posts);
});

it('削除済みレコードは表示されない', function () {
    $post = Post::factory()->create();
    $deletedPost = Post::factory()->deleted()->create();

    livewire(ListPosts::class)
        ->assertCanSeeTableRecords([$post])
        ->assertCanNotSeeTableRecords([$deletedPost]);
});

it('テーブルカラムが存在する', function () {
    livewire(ListPosts::class)
        ->assertTableColumnExists('title')
        ->assertTableColumnExists('status')
        ->assertTableColumnExists('published_at');
});

it('タイトルで検索できる', function () {
    $post = Post::factory()->create(['title' => 'Filamentの使い方']);
    $other = Post::factory()->create(['title' => 'Laravelの基礎']);

    livewire(ListPosts::class)
        ->searchTable('Filament')
        ->assertCanSeeTableRecords([$post])
        ->assertCanNotSeeTableRecords([$other]);
});

it('ステータスでフィルタリングできる', function () {
    $draft = Post::factory()->create(['status' => 'draft']);
    $published = Post::factory()->create(['status' => 'published']);

    livewire(ListPosts::class)
        ->filterTable('status', 'published')
        ->assertCanSeeTableRecords([$published])
        ->assertCanNotSeeTableRecords([$draft]);
});

作成フォームのテスト

it('記事を作成できる', function () {
    livewire(CreatePost::class)
        ->fillForm([
            'title' => '新しい記事タイトル',
            'body' => '記事の本文です。',
            'status' => 'draft',
        ])
        ->call('create')
        ->assertHasNoFormErrors();

    assertDatabaseHas('posts', [
        'title' => '新しい記事タイトル',
        'status' => 'draft',
    ]);
});

it('タイトルは必須', function () {
    livewire(CreatePost::class)
        ->fillForm([
            'title' => '',
            'body' => '本文です。',
        ])
        ->call('create')
        ->assertHasFormErrors(['title' => 'required']);
});

it('タイトルは255文字以内', function () {
    livewire(CreatePost::class)
        ->fillForm([
            'title' => str_repeat('a', 256),
        ])
        ->call('create')
        ->assertHasFormErrors(['title' => 'max']);
});

it('フォームフィールドが存在する', function () {
    livewire(CreatePost::class)
        ->assertFormFieldExists('title')
        ->assertFormFieldExists('body')
        ->assertFormFieldExists('status')
        ->assertFormFieldExists('category_id');
});

編集フォームのテスト

it('記事を編集できる', function () {
    $post = Post::factory()->create(['title' => '元のタイトル']);

    livewire(EditPost::class, ['record' => $post->getRouteKey()])
        ->fillForm([
            'title' => '更新後のタイトル',
        ])
        ->call('save')
        ->assertHasNoFormErrors();

    assertDatabaseHas('posts', [
        'id' => $post->id,
        'title' => '更新後のタイトル',
    ]);
});

it('編集フォームに既存の値が入っている', function () {
    $post = Post::factory()->create([
        'title' => 'テスト記事',
        'status' => 'draft',
    ]);

    livewire(EditPost::class, ['record' => $post->getRouteKey()])
        ->assertFormSet([
            'title' => 'テスト記事',
            'status' => 'draft',
        ]);
});

アクションのテスト

it('記事を削除できる', function () {
    $post = Post::factory()->create();

    livewire(ListPosts::class)
        ->callTableAction(DeleteAction::class, $post)
        ->assertHasNoTableActionErrors();

    assertModelMissing($post);
});

it('カスタムアクション「公開する」が動作する', function () {
    $post = Post::factory()->create(['status' => 'draft']);

    livewire(ListPosts::class)
        ->callTableAction('publish', $post)
        ->assertHasNoTableActionErrors();

    expect($post->fresh()->status)->toBe('published');
});

it('フォーム付きアクション「アーカイブ」が動作する', function () {
    $post = Post::factory()->create(['status' => 'published']);

    livewire(ListPosts::class)
        ->callTableAction('archive', $post, data: [
            'reason' => 'コンテンツが古くなったため',
        ])
        ->assertHasNoTableActionErrors();

    expect($post->fresh())
        ->status->toBe('archived')
        ->archived_reason->toBe('コンテンツが古くなったため');
});

it('一括削除アクションが動作する', function () {
    $posts = Post::factory()->count(3)->create();

    livewire(ListPosts::class)
        ->callTableBulkAction(DeleteBulkAction::class, $posts)
        ->assertHasNoTableActionErrors();

    foreach ($posts as $post) {
        assertModelMissing($post);
    }
});

アクションモーダルのテスト

it('削除確認モーダルが表示される', function () {
    $post = Post::factory()->create();

    livewire(ListPosts::class)
        ->mountTableAction(DeleteAction::class, $post)
        ->assertTableActionHalted(DeleteAction::class);
});

it('アクションのフォームバリデーション', function () {
    $post = Post::factory()->create();

    livewire(ListPosts::class)
        ->callTableAction('archive', $post, data: [
            'reason' => '',  // 必須フィールドを空に
        ])
        ->assertHasTableActionErrors(['reason' => 'required']);
});

認可テスト

it('管理者は記事を削除できる', function () {
    $admin = User::factory()->admin()->create();
    $post = Post::factory()->create();

    actingAs($admin);

    livewire(ListPosts::class)
        ->callTableAction(DeleteAction::class, $post)
        ->assertHasNoTableActionErrors();
});

it('一般ユーザーは記事を削除できない', function () {
    $user = User::factory()->create();  // 管理者ではない
    $post = Post::factory()->create();

    actingAs($user);

    livewire(ListPosts::class)
        ->assertTableActionHidden(DeleteAction::class, $post);
});

it('未認証ユーザーはパネルにアクセスできない', function () {
    $this->get(PostResource::getUrl('index'))
        ->assertRedirect('/admin/login');
});

カスタムページのテスト

it('ダッシュボードページが表示される', function () {
    livewire(Dashboard::class)
        ->assertSuccessful();
});

it('ウィジェットの統計が正しく表示される', function () {
    Post::factory()->count(10)->create(['status' => 'published']);
    Post::factory()->count(3)->create(['status' => 'draft']);

    livewire(PostStatsWidget::class)
        ->assertSee('10')   // 公開記事数
        ->assertSee('3');   // 下書き数
});

Factory の準備

テストを書くにはFactoryの充実が重要です。

// database/factories/PostFactory.php

class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => fake()->sentence(),
            'body' => fake()->paragraphs(3, true),
            'status' => fake()->randomElement(['draft', 'published']),
            'published_at' => fake()->optional()->dateTimeBetween('-1 year', 'now'),
            'user_id' => User::factory(),
            'category_id' => Category::factory(),
        ];
    }

    public function draft(): static
    {
        return $this->state(['status' => 'draft', 'published_at' => null]);
    }

    public function published(): static
    {
        return $this->state([
            'status' => 'published',
            'published_at' => now()->subDays(rand(1, 30)),
        ]);
    }

    public function deleted(): static
    {
        return $this->state(['deleted_at' => now()]);
    }
}

まとめ

Pestとlivewire()ヘルパーを使ったFilamentのテストは、->assertCanSeeTableRecords()->fillForm()->callTableAction()などの専用メソッドで直感的に書けます。フォームバリデーション・アクション実行・認可の3つの観点でテストをカバーすることで、管理画面の品質を保ちながらリファクタリングや機能追加が安全に行えます。次のエピソードでは本番環境でのパフォーマンスと運用を解説します。