Testing CSV importers in Laravel with Pest | Tapix                  [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev) [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog) 

   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing) 

  [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing) 

   ![Tapix](https://tapix.dev/img/tapix-logo-light.svg) 

 TutorialsTesting CSV importers in Laravel with Pest
==========================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Tutorials ](https://tapix.dev/blog/category/tutorials) 

Testing CSV importers in Laravel with Pest
==========================================

 Manch Minasyan ·  July 31, 2026  · 19 min read 

 Testing CSV imports is one of those tasks most Laravel developers skip until it is too late. The feature works in development with a clean test file. It fails in production with a 4,000-row export from a legacy CRM that has blank columns, mismatched header names, and a date format the validation layer has never seen.

The core problem is that testing CSV importers touches multiple system layers at once: file I/O, database state, queue jobs, and side effects like relationship resolution. That combination makes developers reach for manual testing instead of automated tests. This post covers a systematic approach to testing CSV imports in Laravel using Pest v4 -- from unit-level column mapping tests through full pipeline integration tests and Filament wizard component tests.

All examples use the Tapix importer architecture (`BaseImporter`, `ImportField`, `ImportRow`) but the patterns apply to any CSV import system built on Laravel.

[\#](#why-csv-imports-are-hard-to-test "Permalink")Why CSV imports are hard to test
-----------------------------------------------------------------------------------

A CSV import is not a single operation -- it is a pipeline. Each stage can fail independently, and the failure modes are different at each stage.

**File I/O** introduces path-handling edge cases, encoding problems, and memory concerns that do not appear in unit tests against hardcoded arrays. Test files need to exist on disk or be synthesized correctly.

**Database state** matters more for imports than for most features. The execute phase creates or updates records based on what already exists. A test that does not set up the right pre-existing state will pass for the create path and silently miss the update path entirely.

**Queue jobs** run asynchronously. Testing them requires either faking the queue (to assert dispatch) or running them synchronously (to assert outcomes). Both approaches have blind spots if you only use one.

**Side effects** -- relationship resolution, events fired, cache invalidation, external API calls -- are the parts that break silently. A contact import that creates companies via `MatchBehavior::MatchOrCreate` has five ways to go wrong during relationship resolution, and none of them show up in a test that only asserts `assertDatabaseHas(Contact::class, ...)`.

Most developers skip import tests entirely, then discover bugs when a real user uploads a file with edge cases that the developer's own test fixture never included. The solution is not writing one test -- it is testing each layer of the pipeline independently, then combining them in integration tests.

What needs coverage:

- Column mapping: auto-detection, aliases, unknown headers, case sensitivity
- Validation: required fields, field types, choice fields, date formats
- Execution: create path, update path, relationship linking, skip logic
- Edge cases: empty files, all-bad rows, duplicates, special characters
- Queue: job dispatch, uniqueness constraint, failure events
- Filament: wizard navigation, component rendering

[\#](#setting-up-test-fixtures "Permalink")Setting up test fixtures
-------------------------------------------------------------------

Good import tests need realistic CSV data -- not fake files with no content, and not hardcoded strings that do not exercise the actual CSV parser.

### [\#](#creating-test-csv-files "Permalink")Creating test CSV files

Store fixture files in `tests/fixtures/` and commit them to version control. Give each fixture a descriptive name that explains what it tests:

```
mkdir -p tests/fixtures

```

```
tests/fixtures/
  contacts-clean.csv          # 10 rows, all valid
  contacts-messy.csv          # mixed types, blank rows, duplicates
  contacts-empty.csv          # headers only, no data rows
  contacts-utf8.csv           # special characters, non-ASCII names
  contacts-single-row.csv     # exactly one row

```

A clean fixture file for contact imports:

```
first_name,last_name,email,phone,hired_at
Alice,Martin,alice@example.com,+1-555-0101,2024-01-15
Bob,Chen,bob@example.com,+1-555-0102,2023-08-22
Carol,Smith,carol@example.com,+1-555-0103,2024-03-10

```

A messy fixture that exercises edge cases:

```
First Name,Last Name,EMAIL,phone number,Date Hired
Alice,Martin,alice@example.com,555-0101,01/15/2024
,Chen,not-an-email,,2023-08-22
Bob,,bob@example.com,+1-555-0103,
Carol,Smith,carol@example.com,555-0104,2024/03/10
Carol,Smith,carol@example.com,555-0104,2024/03/10

```

Row 2 has a blank first name. Row 3 has an invalid email and blank dates. Row 4 has a blank last name. Rows 5 and 6 are identical duplicates. This is what real data looks like.

### [\#](#synthesizing-uploadedfile-instances "Permalink")Synthesizing UploadedFile instances

Pest tests need an `Illuminate\Http\UploadedFile` to simulate file uploads. Use `UploadedFile::fake()` with real CSV content, not `UploadedFile::fake()->create()` which produces an empty file:

```
use Illuminate\Http\UploadedFile;

function csvUpload(string $fixtureName): UploadedFile
{
    $path = base_path("tests/fixtures/{$fixtureName}");

    return new UploadedFile(
        path: $path,
        originalName: $fixtureName,
        mimeType: 'text/csv',
        error: UPLOAD_ERR_OK,
        test: true,
    );
}

```

Place this helper in `tests/Helpers.php` and autoload it via `composer.json`:

```
"autoload-dev": {
    "files": ["tests/Helpers.php"]
}

```

For in-memory CSV content without a fixture file:

```
function csvFromString(string $content, string $name = 'import.csv'): UploadedFile
{
    $tmp = tempnam(sys_get_temp_dir(), 'csv');
    file_put_contents($tmp, $content);

    return new UploadedFile(
        path: $tmp,
        originalName: $name,
        mimeType: 'text/csv',
        error: UPLOAD_ERR_OK,
        test: true,
    );
}

```

### [\#](#import-and-importrow-factories "Permalink")Import and ImportRow factories

Never create `Import` and `ImportRow` records manually in tests. Use factories so your tests stay independent from the schema and pick up factory state definitions automatically.

A minimal `ImportFactory`:

```
namespace Database\Factories;

use Tapix\Core\Models\Import;
use Tapix\Core\Enums\ImportStatus;
use Illuminate\Database\Eloquent\Factories\Factory;

class ImportFactory extends Factory
{
    protected $model = Import::class;

    public function definition(): array
    {
        return [
            'id'             => fake()->ulid(),
            'importer_class' => \App\Importers\ContactImporter::class,
            'file_name'      => 'contacts.csv',
            'status'         => ImportStatus::Mapping,
            'column_map'     => null,
            'total_rows'     => 0,
        ];
    }

    public function completed(): static
    {
        return $this->state(['status' => ImportStatus::Completed]);
    }

    public function withColumnMap(array $map): static
    {
        return $this->state(['column_map' => $map]);
    }
}

```

[\#](#testing-column-mapping "Permalink")Testing column mapping
---------------------------------------------------------------

Column mapping is the first thing users interact with and the first thing that breaks when their CSV has "First Name" instead of "first\_name". Test it at the unit level against the importer's field definitions.

### [\#](#auto-mapping-correct-headers "Permalink")Auto-mapping correct headers

```
use App\Importers\ContactImporter;
use Tapix\Core\Mapping\ColumnMapper;

test('auto-mapper maps exact header matches', function () {
    $importer = new ContactImporter;
    $headers = ['first_name', 'last_name', 'email'];

    $result = ColumnMapper::autoMap($headers, $importer->fields());

    expect($result)
        ->toHaveKey('first_name', 'first_name')
        ->toHaveKey('last_name', 'last_name')
        ->toHaveKey('email', 'email');
});

```

### [\#](#alias-matching "Permalink")Alias matching

The `guess()` method on `ImportField` registers header aliases. Test that common human-readable variants map to the right field:

```
test('auto-mapper resolves common header aliases', function () {
    $importer = new ContactImporter;
    $headers = ['First Name', 'Last Name', 'Email Address'];

    $result = ColumnMapper::autoMap($headers, $importer->fields());

    expect($result)
        ->toHaveKey('First Name', 'first_name')
        ->toHaveKey('Last Name', 'last_name')
        ->toHaveKey('Email Address', 'email');
});

test('auto-mapper handles informal aliases', function () {
    $importer = new ContactImporter;
    $headers = ['fname', 'lname', 'e-mail'];

    $result = ColumnMapper::autoMap($headers, $importer->fields());

    expect($result['fname'])->toBe('first_name');
    expect($result['lname'])->toBe('last_name');
});

```

### [\#](#unknown-headers-left-unmapped "Permalink")Unknown headers left unmapped

When a CSV header has no matching field, the mapper should not guess or assign it to a random field. Test that unknown headers return a null mapping:

```
test('auto-mapper leaves unknown headers unmapped', function () {
    $importer = new ContactImporter;
    $headers = ['first_name', 'email', 'favorite_color', 'shoe_size'];

    $result = ColumnMapper::autoMap($headers, $importer->fields());

    expect($result)
        ->toHaveKey('first_name', 'first_name')
        ->toHaveKey('email', 'email')
        ->toHaveKey('favorite_color', null)
        ->toHaveKey('shoe_size', null);
});

```

### [\#](#case-insensitive-matching "Permalink")Case-insensitive matching

```
test('auto-mapper matches headers case-insensitively', function (string $header) {
    $importer = new ContactImporter;

    $result = ColumnMapper::autoMap([$header], $importer->fields());

    expect($result[$header])->toBe('email');
})->with(['EMAIL', 'Email', 'eMaIl', 'email']);

```

The `with()` dataset runs this test four times with different case variants. Any one of them failing reveals a case-sensitivity bug in the mapper.

[\#](#testing-validation-rules "Permalink")Testing validation rules
-------------------------------------------------------------------

Validation tests work best at the field level: test one field's rules with valid and invalid inputs, assert the correct error structure. Use datasets to cover multiple bad values without duplicating test bodies.

### [\#](#required-field-with-blank-value "Permalink")Required field with blank value

```
use Tapix\Core\Jobs\ValidateColumnJob;

test('required field rejects blank values', function () {
    $import = Import::factory()->withColumnMap(['first_name' => 'first_name'])->create();

    ValidateColumnJob::dispatchSync($import->id, 'first_name');

    $rows = ImportRow::forImport($import->id)->withErrors()->get();

    expect($rows)->toHaveCount(1)
        ->and($rows->first()->validation)
        ->toHaveKey('first_name');
});

```

### [\#](#email-field-validation "Permalink")Email field validation

```
test('email field rejects invalid formats', function (string $badEmail) {
    $import = Import::factory()->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['email' => $badEmail, 'first_name' => 'Alice'],
    ]);

    ValidateColumnJob::dispatchSync($import->id, 'email');

    $errorRows = ImportRow::forImport($import->id)->withErrors()->get();

    expect($errorRows)->toHaveCount(1);
})->with([
    'plaintext',
    'missing@',
    '@nodomain',
    'double@@at.com',
    '',
]);

test('email field accepts valid addresses', function (string $goodEmail) {
    $import = Import::factory()->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['email' => $goodEmail, 'first_name' => 'Alice'],
    ]);

    ValidateColumnJob::dispatchSync($import->id, 'email');

    $errorRows = ImportRow::forImport($import->id)->withErrors()->get();

    expect($errorRows)->toBeEmpty();
})->with([
    'alice@example.com',
    'alice+tag@example.co.uk',
    'alice.martin@sub.domain.org',
]);

```

### [\#](#date-field-with-ambiguous-format "Permalink")Date field with ambiguous format

Date parsing is one of the most common failure modes in real imports. Test both formats that should parse and formats that should not:

```
test('date field rejects unrecognizable formats', function (string $value) {
    $import = Import::factory()->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['hired_at' => $value, 'first_name' => 'Alice', 'email' => 'a@b.com'],
    ]);

    ValidateColumnJob::dispatchSync($import->id, 'hired_at');

    expect(ImportRow::forImport($import->id)->withErrors()->count())->toBe(1);
})->with([
    'not-a-date',
    '32/01/2024',
    '2024-13-01',
]);

test('date field accepts common date formats', function (string $value) {
    $import = Import::factory()->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['hired_at' => $value, 'first_name' => 'Alice', 'email' => 'a@b.com'],
    ]);

    ValidateColumnJob::dispatchSync($import->id, 'hired_at');

    expect(ImportRow::forImport($import->id)->withErrors()->count())->toBe(0);
})->with([
    '2024-01-15',
    '01/15/2024',
    '15/01/2024',
    'January 15, 2024',
]);

```

### [\#](#choice-field-with-value-not-in-options "Permalink")Choice field with value not in options

```
test('choice field rejects values outside defined options', function () {
    $import = Import::factory()->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['status' => 'superstar', 'first_name' => 'Alice', 'email' => 'a@b.com'],
    ]);

    ValidateColumnJob::dispatchSync($import->id, 'status');

    $row = ImportRow::forImport($import->id)->first();

    expect($row->validation)->toHaveKey('status')
        ->and($row->validation['status'])->toContain('superstar');
});

test('choice field accepts defined options case-insensitively', function (string $value) {
    $import = Import::factory()->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['status' => $value, 'first_name' => 'Alice', 'email' => 'a@b.com'],
    ]);

    ValidateColumnJob::dispatchSync($import->id, 'status');

    expect(ImportRow::forImport($import->id)->withErrors()->count())->toBe(0);
})->with(['active', 'ACTIVE', 'Active', 'inactive']);

```

[\#](#testing-the-full-import-pipeline "Permalink")Testing the full import pipeline
-----------------------------------------------------------------------------------

Integration tests run the entire pipeline -- upload, mapping, validation, execution -- and assert database state and import metrics. These are slower than unit tests, but they catch the bugs that field-level tests miss.

### [\#](#happy-path-end-to-end-create "Permalink")Happy path: end-to-end create

```
use Illuminate\Support\Facades\Queue;

test('full pipeline creates records from clean CSV', function () {
    Queue::fake();

    $csv = csvFromString(
        "first_name,last_name,email\nAlice,Martin,alice@example.com\nBob,Chen,bob@example.com"
    );

    $import = $this->importerService->upload($csv, ContactImporter::class);
    $this->importerService->applyMapping($import, ['first_name' => 'first_name', 'last_name' => 'last_name', 'email' => 'email']);
    $this->importerService->validate($import);
    $this->importerService->execute($import);

    expect(Contact::count())->toBe(2)
        ->and(Contact::where('email', 'alice@example.com')->exists())->toBeTrue()
        ->and(Contact::where('email', 'bob@example.com')->exists())->toBeTrue();

    $import->refresh();
    expect($import->status)->toBe(ImportStatus::Completed);
});

```

### [\#](#update-path-existing-records-matched-by-key "Permalink")Update path: existing records matched by key

```
test('execute updates existing records when match key exists', function () {
    $existing = Contact::factory()->create([
        'email'      => 'alice@example.com',
        'first_name' => 'Alice',
        'last_name'  => 'Old',
    ]);

    $import = Import::factory()
        ->withColumnMap(['email' => 'email', 'first_name' => 'first_name', 'last_name' => 'last_name'])
        ->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['email' => 'alice@example.com', 'first_name' => 'Alice', 'last_name' => 'Martin'],
    ]);

    ExecuteImportJob::dispatchSync($import->id);

    $existing->refresh();

    expect($existing->last_name)->toBe('Martin')
        ->and(Contact::count())->toBe(1);
});

```

### [\#](#relationships-created-correctly "Permalink")Relationships created correctly

```
test('execute creates BelongsTo relationship via MatchOrCreate', function () {
    expect(Company::count())->toBe(0);

    $import = Import::factory()
        ->withColumnMap(['first_name' => 'first_name', 'email' => 'email', 'company' => 'company'])
        ->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['first_name' => 'Alice', 'email' => 'alice@example.com', 'company' => 'Acme Corp'],
    ]);

    ExecuteImportJob::dispatchSync($import->id);

    expect(Company::where('name', 'Acme Corp')->exists())->toBeTrue()
        ->and(Contact::first()->company->name)->toBe('Acme Corp');
});

test('execute reuses existing company on second import row', function () {
    $import = Import::factory()
        ->withColumnMap(['first_name' => 'first_name', 'email' => 'email', 'company' => 'company'])
        ->create();

    ImportRow::factory()->for($import)->createMany([
        ['raw_data' => ['first_name' => 'Alice', 'email' => 'alice@example.com', 'company' => 'Acme Corp']],
        ['raw_data' => ['first_name' => 'Bob',   'email' => 'bob@example.com',   'company' => 'Acme Corp']],
    ]);

    ExecuteImportJob::dispatchSync($import->id);

    expect(Company::where('name', 'Acme Corp')->count())->toBe(1)
        ->and(Contact::count())->toBe(2);
});

```

### [\#](#import-row-counts "Permalink")Import row counts

After execution, the import model tracks how many rows were created, updated, skipped, and failed. Assert these counts explicitly:

```
test('import counts reflect actual processing outcomes', function () {
    $existing = Contact::factory()->create(['email' => 'existing@example.com']);

    $import = Import::factory()
        ->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
        ->create();

    ImportRow::factory()->for($import)->createMany([
        ['raw_data' => ['first_name' => 'Alice', 'email' => 'new@example.com']],
        ['raw_data' => ['first_name' => 'Bob',   'email' => 'existing@example.com']],
        ['raw_data' => ['first_name' => '',       'email' => 'bad@example.com'], 'skipped' => true],
    ]);

    ExecuteImportJob::dispatchSync($import->id);

    $import->refresh();

    expect($import->created_rows)->toBe(1)
        ->and($import->updated_rows)->toBe(1)
        ->and($import->skipped_rows)->toBe(1)
        ->and($import->failed_rows)->toBe(0);
});

```

[\#](#testing-edge-cases "Permalink")Testing edge cases
-------------------------------------------------------

Edge cases are what break real imports. Every test suite for a CSV importer should cover these explicitly.

### [\#](#empty-file-headers-only-no-data-rows "Permalink")Empty file (headers only, no data rows)

```
test('empty CSV with only headers completes without creating records', function () {
    $csv = csvFromString("first_name,last_name,email\n");

    $import = $this->importerService->upload($csv, ContactImporter::class);
    $this->importerService->applyMapping($import, [
        'first_name' => 'first_name',
        'last_name'  => 'last_name',
        'email'      => 'email',
    ]);
    $this->importerService->execute($import);

    expect(Contact::count())->toBe(0);

    $import->refresh();
    expect($import->status)->toBe(ImportStatus::Completed)
        ->and($import->total_rows)->toBe(0);
});

```

### [\#](#all-rows-have-validation-errors "Permalink")All rows have validation errors

```
test('import with zero valid rows completes with failed status', function () {
    $import = Import::factory()
        ->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
        ->create();

    ImportRow::factory()->for($import)->createMany([
        ['raw_data' => ['first_name' => 'Alice', 'email' => 'not-an-email']],
        ['raw_data' => ['first_name' => 'Bob',   'email' => 'also-not']],
    ]);

    ValidateColumnJob::dispatchSync($import->id, 'email');
    ExecuteImportJob::dispatchSync($import->id);

    expect(Contact::count())->toBe(0);

    $import->refresh();
    expect($import->failed_rows)->toBe(2)
        ->and($import->created_rows)->toBe(0);
});

```

### [\#](#duplicate-rows-in-the-same-file "Permalink")Duplicate rows in the same file

Intra-file deduplication should prevent the same logical record from being created twice. For more on this pattern, see [Intra-import deduplication](/blog/intra-import-deduplication) -- but always cover it in tests:

```
test('duplicate rows in the same file create only one record', function () {
    $import = Import::factory()
        ->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
        ->create();

    $row = ['first_name' => 'Alice', 'email' => 'alice@example.com'];

    ImportRow::factory()->for($import)->createMany([
        ['raw_data' => $row],
        ['raw_data' => $row],
        ['raw_data' => $row],
    ]);

    ExecuteImportJob::dispatchSync($import->id);

    expect(Contact::where('email', 'alice@example.com')->count())->toBe(1);
});

```

### [\#](#special-characters-and-utf-8 "Permalink")Special characters and UTF-8

```
test('import handles UTF-8 special characters in field values', function () {
    $import = Import::factory()
        ->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
        ->create();

    ImportRow::factory()->for($import)->create([
        'raw_data' => ['first_name' => 'Ångström Müller-Lüdenscheidt', 'email' => 'angstrom@example.com'],
    ]);

    ExecuteImportJob::dispatchSync($import->id);

    expect(Contact::where('email', 'angstrom@example.com')->value('first_name'))
        ->toBe('Ångström Müller-Lüdenscheidt');
});

test('import handles values with embedded commas and quotes', function () {
    $csv = csvFromString(
        "first_name,last_name,email\n\"Smith, Jr.\",\"O'Brien\",smithjr@example.com"
    );

    $import = $this->importerService->upload($csv, ContactImporter::class);
    $this->importerService->applyMapping($import, [
        'first_name' => 'first_name',
        'last_name'  => 'last_name',
        'email'      => 'email',
    ]);
    $this->importerService->execute($import);

    expect(Contact::where('email', 'smithjr@example.com')->value('first_name'))
        ->toBe('Smith, Jr.');
});

```

[\#](#testing-queue-jobs "Permalink")Testing queue jobs
-------------------------------------------------------

Queue tests split into two modes: fake queue for asserting dispatch behavior, synchronous queue for asserting outcomes.

### [\#](#assert-jobs-are-dispatched "Permalink")Assert jobs are dispatched

```
use Illuminate\Support\Facades\Queue;
use Tapix\Core\Jobs\ExecuteImportJob;
use Tapix\Core\Jobs\ValidateColumnJob;

test('starting import dispatches validation jobs for each mapped column', function () {
    Queue::fake();

    $import = Import::factory()
        ->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
        ->create();

    $this->importerService->startValidation($import);

    Queue::assertPushed(ValidateColumnJob::class, 2);

    Queue::assertPushed(ValidateColumnJob::class, function (ValidateColumnJob $job) use ($import) {
        return $job->importId === $import->id && $job->column === 'first_name';
    });
});

test('executing import dispatches ExecuteImportJob', function () {
    Queue::fake();

    $import = Import::factory()->create();

    $this->importerService->execute($import);

    Queue::assertPushed(ExecuteImportJob::class, function (ExecuteImportJob $job) use ($import) {
        return $job->importId === $import->id;
    });
});

```

### [\#](#shouldbeunique-prevents-duplicate-dispatch "Permalink")ShouldBeUnique prevents duplicate dispatch

`ExecuteImportJob` implements `ShouldBeUnique` to prevent the same import from being executed twice concurrently. Test this with the sync driver, which surfaces uniqueness failures immediately. See the [Laravel queue testing docs](https://laravel.com/docs/queues#testing) for queue fake options:

```
test('dispatching ExecuteImportJob twice only runs it once', function () {
    config(['queue.default' => 'sync']);

    $import = Import::factory()->create();
    $count = 0;

    // Wrap dispatch in a Job mock that counts invocations
    // In practice, assert the unique key is set correctly
    $job = new ExecuteImportJob($import->id);

    expect($job->uniqueId())->toBe($import->id);
});

```

### [\#](#job-failure-fires-importfailed-event "Permalink")Job failure fires ImportFailed event

```
use Tapix\Core\Events\ImportFailed;
use Illuminate\Support\Facades\Event;

test('ExecuteImportJob failure dispatches ImportFailed event', function () {
    Event::fake([ImportFailed::class]);

    $import = Import::factory()->create();

    // Simulate a job failure by dispatching with invalid state
    // Force the job to fail by corrupting importer class
    $import->update(['importer_class' => 'NonExistentImporter']);

    try {
        ExecuteImportJob::dispatchSync($import->id);
    } catch (\Throwable) {
        // Expected -- we forced a failure
    }

    Event::assertDispatched(ImportFailed::class, function (ImportFailed $event) use ($import) {
        return $event->import->id === $import->id;
    });
});

```

### [\#](#end-to-end-with-sync-driver "Permalink")End-to-end with sync driver

The sync driver is the most reliable way to test the full async pipeline in tests because it runs jobs inline, without mocking:

```
test('full pipeline runs end-to-end with sync queue driver', function () {
    config(['queue.default' => 'sync']);

    $csv = csvFromString(
        "first_name,email\nAlice,alice@example.com\nBob,bob@example.com"
    );

    $import = $this->importerService->upload($csv, ContactImporter::class);

    $this->importerService->applyMapping($import, [
        'first_name' => 'first_name',
        'email'      => 'email',
    ]);

    $this->importerService->runPipeline($import);

    expect(Contact::count())->toBe(2);

    $import->refresh();
    expect($import->status)->toBe(ImportStatus::Completed);
});

```

[\#](#testing-filament-integration "Permalink")Testing Filament integration
---------------------------------------------------------------------------

The Filament plugin adds an import wizard page and an import history resource to your panel. Test these with Livewire's test helpers. See [Adding a CSV import wizard to your Filament panel](/blog/filament-csv-import-panel) for the plugin registration steps and [The complete guide to CSV imports in Laravel](/blog/complete-guide-csv-imports-laravel) for background.

### [\#](#plugin-page-renders "Permalink")Plugin page renders

```
use function Pest\Livewire\livewire;
use Tapix\Core\Livewire\ImportWizard;

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

test('import wizard page renders', function () {
    livewire(ImportWizard::class, [
        'importerClass' => ContactImporter::class,
    ])
    ->assertSuccessful()
    ->assertSee('Upload');
});

test('import history page renders', function () {
    livewire(\Tapix\Core\Filament\Resources\ImportResource\Pages\ListImports::class)
        ->assertSuccessful()
        ->assertSeeText('Imports');
});

```

### [\#](#wizard-step-navigation "Permalink")Wizard step navigation

Test that advancing through the wizard steps works correctly and that the component transitions state:

```
test('upload step transitions to mapping step on valid file', function () {
    $component = livewire(ImportWizard::class, [
        'importerClass' => ContactImporter::class,
    ]);

    $csv = csvFromString("first_name,email\nAlice,alice@example.com");

    $component
        ->set('file', $csv)
        ->call('upload')
        ->assertSet('currentStep', 'mapping');
});

test('mapping step shows detected column headers', function () {
    $import = Import::factory()
        ->withColumnMap(['first_name' => null, 'email' => null])
        ->create();

    livewire(ImportWizard::class, [
        'importerClass' => ContactImporter::class,
        'storeId'       => $import->id,
    ])
    ->assertSeeText('first_name')
    ->assertSeeText('email');
});

test('review step shows validation error count', function () {
    $import = Import::factory()->create();

    ImportRow::factory()->for($import)->create([
        'raw_data'   => ['first_name' => '', 'email' => 'bad'],
        'validation' => ['first_name' => ['This field is required.'], 'email' => ['Invalid email address.']],
    ]);

    livewire(ImportWizard::class, [
        'importerClass' => ContactImporter::class,
        'storeId'       => $import->id,
    ])
    ->call('goToStep', 'review')
    ->assertSee('2 errors');
});

```

For Pest Livewire integration, see the [Pest PHP expectations docs](https://pestphp.com/docs/expectations) for fluent assertion chaining available in Livewire component tests.

[\#](#a-testing-checklist "Permalink")A testing checklist
---------------------------------------------------------

Every importer that ships to production should have these tests passing:

**Mapping layer:**

- Auto-mapper correctly identifies exact header matches
- Alias matching works for at least three common variants per field
- Unknown headers are left unmapped, not silently dropped or wrong-assigned
- Case-insensitive matching

**Validation layer:**

- Each required field rejects blank values
- Each typed field (email, date, number, boolean, choice) rejects at least three invalid inputs
- Valid inputs for each field type pass without errors
- Dataset-driven tests so new bad inputs can be added as a one-liner

**Execution layer:**

- Create path: new records are inserted with correct field values
- Update path: existing records matched by key are updated, not duplicated
- Relationship resolution: `MatchOrCreate` creates missing related records, `MatchOnly` skips unmatched rows
- Import row counts match actual create/update/skip/fail outcomes
- Import status transitions to `Completed` on success

**Edge cases:**

- Empty file (headers only) completes with zero records and zero errors
- All-invalid rows: import completes, zero records created
- Duplicate rows: only one record created per unique key
- UTF-8 characters in string fields are preserved
- CSV values with commas and quotes parse correctly

**Queue:**

- Validation jobs are dispatched for each mapped column
- `ExecuteImportJob` has a `uniqueId()` based on import ID
- Job failure fires `ImportFailed` event
- Full pipeline passes with sync driver end-to-end

**Filament:**

- Wizard page renders without errors for authenticated users
- Upload step advances to mapping step on valid file
- Review step shows correct error counts

For deeper coverage of what happens when validation fails and users correct errors inline, see [Handling CSV validation errors before they hit your database](/blog/handling-csv-validation-errors) and [CSV import error recovery: from silent failures to user-friendly correction](/blog/csv-import-error-recovery).

For performance testing patterns with large files and queue worker behavior under load, see [Queue-powered imports: processing 100K rows in Laravel](/blog/queue-powered-imports-100k-rows).

 ### Enjoyed this post?

Get notified when we publish new articles about Laravel imports and data handling.

  Email address   Subscribe         

Almost there — confirm your subscription via email.

 Related posts
-------------

 [  Tutorials   Jul 28, 2026  

 How to import CSV files in Filament: basic to advanced 
--------------------------------------------------------

A complete guide to CSV imports in Filament -- from the built-in Import Action through advanced customization, with an honest look at where it hits its limits and when Tapix makes sense.

 ](https://tapix.dev/blog/csv-import-filament-basic-to-advanced) [  Tutorials   Jul 24, 2026  

 Importing Excel (XLSX) files in Laravel 
-----------------------------------------

A practical breakdown of every way to import XLSX files in Laravel -- from raw PhpSpreadsheet to Tapix -- with real code and honest tradeoffs for each approach.

 ](https://tapix.dev/blog/importing-xlsx-excel-files-laravel) [  Tutorials   Jul 21, 2026  

 How to fix slow CSV imports in Laravel 
----------------------------------------

A diagnostic guide to the four most common Laravel CSV import performance problems -- memory exhaustion, timeout errors, N+1 queries, and file size limits -- with copy-pasteable fixes for each.

 ](https://tapix.dev/blog/fix-slow-csv-imports-laravel) 

   [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev)CSV and Excel import wizard for Laravel.

  Product [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog) [Contact](mailto:hello@tapix.dev) 

 Compare [vs Laravel Excel](https://tapix.dev/vs/laravel-excel) [vs Filament Import](https://tapix.dev/vs/filament-import) 

 Legal [Privacy](https://tapix.dev/privacy-policy) [Terms](https://tapix.dev/terms-of-service) 

© 2026 Tapix. All rights reserved.
