> ## Documentation Index
> Fetch the complete documentation index at: https://filamentphp.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing exports

export const EditOnGitHub = ({version, path}) => {
  const url = `https://github.com/filamentphp/filament/edit/${version}/${path}`;
  return <div className="not-prose mt-16">
      <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2 text-sm text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="h-4 w-4">
          <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
        </svg>
        Edit this page on GitHub
      </a>
    </div>;
};

export const Footer = () => {
  const sponsorsByTier = JSON.parse(`{
  "agency_partner": [
    {
      "name": "Kirschbaum",
      "url": "https://kirschbaumdevelopment.com/solutions/filament-development",
      "filename": "kirschbaum.svg"
    }
  ],
  "gold": [
    {
      "name": "Agiledrop",
      "url": "https://www.agiledrop.com/laravel?utm_source=filament",
      "filename": "agiledrop.svg"
    },
    {
      "name": "Baiz.ai",
      "url": "https://baiz.ai",
      "filename": "baiz-ai.svg"
    },
    {
      "name": "Mailtrap",
      "url": "https://mailtrap.io/email-sending?utm_source=community&utm_medium=referral&utm_campaign=filament",
      "filename": "mailtrap.svg"
    },
    {
      "name": "SerpApi",
      "url": "https://serpapi.com/?utm_source=filamentphp",
      "filename": "serpapi.svg"
    }
  ]
}`);
  function shuffleArray(items) {
    const result = [...items];
    for (let index = result.length - 1; index > 0; index--) {
      const randomIndex = Math.floor(Math.random() * (index + 1));
      [result[index], result[randomIndex]] = [result[randomIndex], result[index]];
    }
    return result;
  }
  const sponsors = Object.entries(sponsorsByTier).flatMap(([, sponsors]) => shuffleArray(sponsors));
  return <div className="mt-16 flex flex-col gap-4">
      <h2 className="text-center text-2xl font-medium text-gray-800 dark:text-gray-200">
        Sponsored by
      </h2>

      <div className="not-prose flex flex-wrap items-center justify-center gap-5">
        {sponsors.map(sponsor => <a key={sponsor.name} className="footer-sponsor-card" href={sponsor.url} target="_blank" title={sponsor.name}>
            <img src={`/docs/images/sponsors/footer/${sponsor.filename}`} alt={sponsor.name} noZoom />
            <span className="line-pattern-overlay line-pattern-80" />
          </a>)}

        <a href="https://github.com/sponsors/danharrin" target="_blank" className="footer-sponsor-cta">
          <span className="sponsor-cta-content">
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="M5 12h14" />
              <path d="M12 5v14" />
            </svg>
            <span>Your logo here</span>
          </span>
          <span className="line-pattern-overlay line-pattern-60" />
        </a>
      </div>
    </div>;
};

## Introduction

You can test [exports](../actions/export) at two levels: call `test()` on your exporter to check row values, and `ExportAction::fake()` to check an action's form, column selection, options, and query without running export jobs.

## Testing row values

Call `test()` on your exporter to create a `Filament\Actions\Testing\TestableExport`, then call `export()` with an Eloquent record:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Exports\ProductExporter;
use App\Models\Product;

it('exports product details', function () {
    $product = Product::factory()->make([
        'name' => 'Oak desk',
        'sku' => 'DESK-01',
    ]);

    $row = ProductExporter::test(columnMap: [
        'name' => 'Name',
        'sku' => 'SKU',
    ])->export($product);

    expect($row)->toBe(['Oak desk', 'DESK-01']);
});
```

The helper calls your real exporter, including state and formatting callbacks, and returns its array unchanged. The standard exporter returns positional values in `columnMap` order, not values keyed by column names or labels. Exceptions propagate to your test.

Omitting `columnMap` or passing `null` selects visible, default-enabled columns with their labels in declaration order. An explicit map selects exactly those columns, including hidden or default-disabled columns; `[]` selects none. This does not reproduce action-specific selection such as `columnMapping(false)` or table column visibility. Test those through the action.

You may pass an `Export` model as the `export` argument when callbacks need export context. Otherwise, the helper uses an unsaved model. It does not save either model or authenticate as the export's owner. Set up authentication yourself when needed, and pass the map and options explicitly rather than relying on values stored on the export model.

### Testing formatting and options

For example, a `PostExporter` could calculate and format a score using an option:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Models\Post;
use Filament\Actions\Exports\ExportColumn;

ExportColumn::make('score')
    ->state(static fn (Post $record, array $options): int => $record->rating * ($options['multiplier'] ?? 1))
    ->formatStateUsing(static fn (int $state): string => "{$state} points")
```

Pass `options` to check how they change the exported value:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Exports\PostExporter;
use App\Models\Post;

it('exports calculated scores', function () {
    $post = Post::factory()->make(['rating' => 3]);
    $columnMap = ['score' => 'Score'];

    expect(PostExporter::test($columnMap, options: ['multiplier' => 2])->export($post))
        ->toBe(['6 points'])
        ->and(PostExporter::test($columnMap, options: ['multiplier' => 5])->export($post))
        ->toBe(['15 points']);
});
```

Options pass through unchanged: the helper does not apply options-form defaults or validation. If the form defaults `multiplier` to `2`, pass it explicitly; omitting it above uses the callback's fallback of `1`.

### Preparing relationships and aggregates

The helper does not call `modifyQuery()`, eager-load relationships, or prepare aggregates from columns such as `ExportColumn::make('posts_count')->counts('posts')`. Prepare the record yourself, matching any relationship scopes used by the exporter. For an `AuthorExporter` with `team.name`, `posts_count`, and `posts_sum_rating` columns, an author on the `Editorial` team with two posts rated `3` and `8` could be tested with:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Exports\AuthorExporter;

$author->load('team')->loadCount('posts')->loadSum('posts', 'rating');

expect(AuthorExporter::test()->export($author))
    ->toBe(['Editorial', '2', '11']);
```

You can reuse a helper for different records. If you change a saved record or load more attributes after exporting it, call `test()` again to create a fresh helper, since columns cache record state. Callbacks and Eloquent lazy loading may still issue queries.

## Testing export actions

Call `ExportAction::fake()` before calling the action, then use the returned fake to assert that an export was dispatched:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Exports\ProductExporter;
use App\Filament\Resources\Products\Pages\ListProducts;
use App\Models\User;
use Filament\Actions\ExportAction;
use Filament\Actions\Testing\TestAction;

use function Pest\Livewire\livewire;

it('starts a product export', function () {
    $this->actingAs(User::factory()->create());
    $exports = ExportAction::fake();

    livewire(ListProducts::class)
        ->callAction(TestAction::make('export')->table())
        ->assertHasNoFormErrors();

    $exports->assertDispatched(ProductExporter::class)
        ->assertDispatchedTimes(ProductExporter::class);
});
```

This assumes an action named `export` in the table header. For an action outside a table, use `callAction('export')`. Set up the page's normal panel, tenant, and authentication context in these tests.

`assertDispatchedTimes()` checks the exact count, defaulting to `1`. Use `assertNothingDispatched()` after a rejected submission. `ExportBulkAction::fake()` captures both action types too. Calling either `fake()` method resets the recorded exports, so call it once before actions you want to assert together.

Use `assertNotDispatched(ProductExporter::class)` to check that a particular exporter was not dispatched, while allowing other exporters. It accepts the same optional callback as `assertDispatched()` and fails if any export matches:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Exports\ProductExporter;
use Filament\Actions\Exports\Models\Export;

$exports->assertNotDispatched(ProductExporter::class, static fn (Export $export): bool => $export->user_id === $otherUser->getKey());
```

### Inspecting the query and submitted configuration

Pass a callback to `assertDispatched()` to inspect the saved `Export`, a fresh query builder, column map, merged options, formats, and selected record IDs, in that order. Return `true` for a match or `false` to try another export. You may use Pest or PHPUnit assertions inside the callback.

For a table with a searchable `name` column and a `ProductExporter` with only a `name` export column:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Exports\ProductExporter;
use App\Filament\Resources\Products\Pages\ListProducts;
use App\Models\Product;
use Filament\Actions\ExportAction;
use Filament\Actions\Exports\Models\Export;
use Filament\Actions\Testing\TestAction;
use Illuminate\Database\Eloquent\Builder;

use function Pest\Livewire\livewire;

$matchingProduct = Product::factory()->create(['name' => 'Oak desk']);
Product::factory()->create(['name' => 'Walnut chair']);
$exports = ExportAction::fake();

livewire(ListProducts::class)
    ->searchTable('Oak')
    ->callAction(TestAction::make('export')->table(), data: [
        'columnMap' => [
            'name' => ['isEnabled' => true, 'label' => 'Product name'],
        ],
    ])
    ->assertHasNoFormErrors();

$exports->assertDispatched(ProductExporter::class, function (Export $export, Builder $query, array $columnMap) use ($matchingProduct): bool {
    expect($query->pluck('id')->all())->toBe([$matchingProduct->getKey()])
        ->and($columnMap)->toBe(['name' => 'Product name'])
        ->and($export->total_rows)->toBe(1);

    return true;
});
```

A partial `columnMap` in `callAction(data: ...)` does not replace the whole mapping: other default-enabled columns remain selected unless you set their `isEnabled` to `false`. Form defaults and validation run normally, and submitted options override keys from the action's static `options()`.

The query includes table filters, search, ordering, exporter `modifyQuery()`, and action `modifyQueryUsing()`. The fake deserializes a fresh builder for each callback without executing it or preparing relationships and aggregates. Calling `pluck()` executes it against your test database; changing the builder does not affect later assertions. The action itself still queries and counts records during preparation.

### Inspecting bulk selections

Selected IDs are captured separately from the base query. For a table and exporter that include all three products:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Exports\ProductExporter;
use App\Filament\Resources\Products\Pages\ListProducts;
use App\Models\Product;
use Filament\Actions\ExportBulkAction;
use Filament\Actions\Exports\Models\Export;
use Filament\Actions\Testing\TestAction;
use Illuminate\Database\Eloquent\Builder;

use function Pest\Livewire\livewire;

$products = Product::factory()->count(3)->create();
$exports = ExportBulkAction::fake();

livewire(ListProducts::class)
    ->selectTableRecords([$products[1]->getKey()])
    ->callAction(TestAction::make('export')->table()->bulk())
    ->assertHasNoFormErrors();

$exports->assertDispatched(ProductExporter::class, function (
    Export $export,
    Builder $query,
    array $columnMap,
    array $options,
    array $formats,
    ?array $records,
) use ($products): bool {
    expect($query->reorder('id')->pluck('id')->all())->toBe($products->modelKeys())
        ->and($records)->toBe([$products[1]->getKey()])
        ->and($export->total_rows)->toBe(1);

    return true;
});
```

Do not assume the query is restricted to the selection. Non-bulk exports pass `null` for the selected IDs. Scope the query to records the user may access; the fake does not add [per-record authorization](../actions/export#per-record-authorization).

### Understanding the fake's boundaries

The action still persists an `Export`, enforces row limits, and deletes its existing export directory. Configuration callbacks, action hooks, and model events still run. Use your normal test database and Laravel's `Storage::fake()` on the configured export disk to isolate storage cleanup. Started notifications follow the action's queue configuration; unrelated jobs and listeners are not faked.

The fake only intercepts dispatch through Filament's `ExportDispatcher`. If your custom action dispatches jobs directly instead, use Laravel's bus or queue fakes to test that workflow.

The fake does not process rows, generate files, or complete exports and send completion notifications. Keep separate unfaked integration tests for worker processing, CSV/XLSX contents, and downloads. Use your exporter's `test()` method for row values, including any [formula injection protection](../actions/export#csv-formula-injection) configured on your columns.

<EditOnGitHub version="5.x" path="docs/10-testing/08-testing-exports.md" />

<Footer />
