> ## 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 imports

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 your importer's row processing using its `test()` method, and test submissions from an import action using `ImportAction::fake()`.

## Testing a row

Call `test()` on your importer, such as `PostImporter::test()`, to get a `Filament\Actions\Testing\TestableImport` instance. It runs your application's importer, including column mapping, casting, validation, lifecycle hooks, and saving records and relationships, without uploading a CSV or dispatching import jobs:

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

it('imports a product', function () {
    $record = ProductImporter::test()->import([
        'sku' => 'MUG-001',
        'name' => 'Ceramic mug',
        'price' => '12.50',
    ])->assertImported()->getRecord();

    $this->assertDatabaseHas('products', [
        'id' => $record->getKey(),
        'sku' => 'MUG-001',
        'name' => 'Ceramic mug',
        'price' => 12.50,
    ]);
});
```

`assertImported()` checks that the importer completed without an exception and resolved a record. It does not guarantee database persistence, since your importer may customize `saveRecord()`. Use `getRecord()` with model or database assertions to check the saved values.

The helper runs your importer's database writes and other side effects without rolling them back, even when a row fails. Use your normal database isolation for tests. Test queued processing and completion notifications separately.

### Passing column mappings and options

By default, each importer column is mapped to a row key with the same name. Pass a `columnMap` to use different CSV headers, and `options` to test your [import options](../actions/import#using-import-options):

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

$product = Product::factory()->create(['sku' => 'MUG-001', 'name' => 'Ceramic mug']);

$record = ProductImporter::test(columnMap: [
    'sku' => 'Product code',
    'name' => 'Product name',
], options: [
    'updateExisting' => true,
])->import([
    'Product code' => 'MUG-001',
    'Product name' => 'Large ceramic mug',
])->assertImported()->getRecord();

expect($record->is($product))->toBeTrue();
expect($product->fresh()->name)->toBe('Large ceramic mug');
```

An explicit map replaces the default entirely; omitted columns remain unmapped. Options control only the behavior you implement in your importer.

The default map includes every declared column, not just the keys present in the row. To test a file that omits a column, pass an explicit map without that column. Omitting a row value alone does not remove the column's validation rules.

`TestableImport` does not validate the column mapping or options forms, or apply options-form defaults. For example, `requiredMapping()` is enforced by the import action's form, not this helper. Test those requirements through the [import action](#testing-import-action-submissions).

### Providing import context

The helper creates an unsaved `Import` model by default. If your importer needs a particular import or associated user, pass your own model to `ProductImporter::test(import: $import)`. Associate its user using `$import->user()->associate($user)` and authenticate explicitly using `$this->actingAs($user)` when needed. The helper does not associate a user or change authentication for you.

If your importer uses the import ID, for example to scope cached values shared across rows, give your supplied model a key appropriate to your application or persist it when a database record is required. The helper does not generate an ID. Use the same import ID for rows from one import and different IDs when testing isolation between imports.

## Asserting skipped rows

Use `assertSkipped()` when your importer's `resolveRecord()` returns `null`. For example, if your importer returns `null` for products that do not exist:

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

ProductImporter::test()->import([
    'sku' => 'MISSING-001',
])->assertSkipped();

$this->assertDatabaseMissing('products', ['sku' => 'MISSING-001']);
```

A skipped row completes without an exception and has no record. It is not a validation error or a deliberate row failure.

## Asserting validation errors

Use `assertHasErrors()` and `assertHasNoErrors()` to check validation errors, including those raised by lifecycle hooks:

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

ProductImporter::test()->import([
    'sku' => 'MUG-001',
    'name' => 'Ceramic mug',
    'price' => '-1',
])->assertHasErrors(['price' => 'min'])
    ->assertHasNoErrors(['name']);
```

Without arguments, these methods check for any validation errors or none. You can pass a field list such as `['price', 'name']`, or a field-to-rule map such as `['price' => ['numeric', 'min']]`. Only the specified fields or rules are checked. Use rule names without parameters, such as `'min'`, since rule parameters are not compared.

For column validation, use importer column names, not CSV headers or display labels. For example, assert `'price'` even when it is mapped to `'Unit price'`.

Exceptions created with `ValidationException::withMessages()` have no failed-rule metadata. Assert their field keys or exact messages. To check that a field has no errors, use `assertHasNoErrors(['custom'])`, since a rule-qualified negative assertion may pass despite a message on that field.

## Asserting deliberate row failures

Use `assertHasRowFailure()` to check for a `RowImportFailedException`. For example, the [updates-only importer](../actions/import#updating-existing-records-when-importing-only) can throw when no matching product exists:

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

ProductImporter::test()->import([
    'sku' => 'MISSING-001',
])->assertHasRowFailure('No product found with SKU [MISSING-001].');
```

Omit the message to check for any deliberate row failure, or pass a message to check an exact match. Use `assertHasNoRowFailure()` to check that none occurred.

Validation errors and deliberate row failures are separate: `assertHasNoErrors()` only checks validation, and `assertHasNoRowFailure()` only checks deliberate failures. Neither establishes an imported outcome; use `assertImported()` for that. Unexpected exceptions propagate to your test.

## Testing relationships

Use model assertions to check saved relationships. For example, for a `PostImporter` that [resolves authors by email](../actions/import#customizing-the-relationship-import-resolution):

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Imports\PostImporter;
use App\Models\User;

it('associates the author matched by email', function () {
    User::factory()->create(['email' => 'grace@example.com']);
    $author = User::factory()->create(['email' => 'ada@example.com']);

    $record = PostImporter::test()->import([
        'title' => 'Importing posts',
        'content' => 'A practical guide',
        'author' => 'ada@example.com',
    ])->assertImported()->getRecord();

    expect($record->fresh()->author->is($author))->toBeTrue();
    $this->assertDatabaseCount('users', 2);
});
```

Also test your resolver's behavior for missing relationships, whether it rejects the row or creates a related record. For other hook side effects, use Laravel's fakes or database assertions, such as checking the record ID passed to a dispatched job.

## Testing import action submissions

Use `ImportAction::fake()` with the [action testing helpers](./testing-actions) to check that your action requests an import without processing its rows. For a page with an `import` action using `ProductImporter` and its `updateExisting` option:

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Imports\ProductImporter;
use App\Filament\Resources\Products\Pages\ListProducts;
use App\Models\User;
use Filament\Actions\ImportAction;
use Filament\Actions\Imports\Models\Import;
use Illuminate\Http\UploadedFile;

use function Pest\Livewire\livewire;

it('requests a product import', function () {
    $user = User::factory()->create();
    $this->actingAs($user);
    $imports = ImportAction::fake();

    livewire(ListProducts::class)
        ->mountAction('import')
        ->fillForm([
            'file' => UploadedFile::fake()->createWithContent(
                'products.csv',
                "Product code,Product name,Unit price\nMUG-001,Ceramic mug,12.50\n",
            ),
        ])
        ->fillForm([
            'columnMap' => ['sku' => 'Product code', 'name' => 'Product name', 'price' => 'Unit price'],
            'updateExisting' => true,
        ])
        ->callMountedAction()
        ->assertHasNoFormErrors();

    $imports->assertDispatched(ProductImporter::class, function (Import $import, array $columnMap, array $options) use ($user): bool {
        return $import->user->is($user)
            && ($columnMap['sku'] === 'Product code')
            && ($options['updateExisting'] === true);
    })->assertDispatchedTimes(ProductImporter::class);
});
```

Upload the file before setting `columnMap`, so the form can read the headers and build its mapping fields. `fillForm()` preserves other form defaults. The callback receives the `Import` model, column map, and options merged from the action and form.

`assertDispatched()` checks for at least one request for the importer, optionally matching a callback. `assertDispatchedTimes()` checks its exact count, defaulting to one. Use `assertNothingDispatched()` after invalid form data or rejected authorization.

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

```php theme={"theme":"gruvbox-dark-hard"}
use App\Filament\Imports\ProductImporter;
use Filament\Actions\Imports\Models\Import;

$imports->assertNotDispatched(ProductImporter::class, static fn (Import $import): bool => $import->user_id === $otherUser->getKey());
```

For example, if `sku` uses `requiredMapping()`, submit an otherwise valid form with `columnMap.sku` set to `null`, assert `assertHasFormErrors(['columnMap.sku' => 'required'])`, then `$imports->assertNothingDispatched()`. These are form errors, not row validation errors.

To test submission-time authorization, mount and fill a valid form while authorized, revoke permission, and invoke `->call('callMountedAction')` before asserting that nothing was dispatched. Visibility checks alone do not prove that submission is rejected.

The fake still reads the file, validates the form, and persists an `Import` record. It does not run import jobs, process rows, emit `ImportStarted` or `ImportCompleted`, or send completion notifications. It does not globally fake Laravel's bus or events: unrelated jobs and events, and your action hooks, still run. Use your importer's `test()` method separately to test row behavior.

The fake only intercepts dispatch through Filament's `ImportDispatcher`. If your custom action dispatches jobs directly instead, use Laravel's bus or queue fakes to test that workflow. Keep separate tests for completion listeners and unfaked integration tests for worker processing and failed-row downloads.

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

<Footer />
