byrcsc/laravel-data-sync · 1.x
Testing.
Test a sync definition end to end with faked disks, assert counters and failed rows, verify queue dispatch and events, and avoid the traps the checksum ledger sets for tests.
A definition is best tested the way it runs: put a real file on a faked disk and process it in-process. Everything below uses Pest syntax and Laravel's own fakes; nothing package-specific is required.
Setting up
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
Storage::fake('incoming');
Storage::fake('staging');
Storage::fake('archive');
config()->set('data-sync.staging.disk', 'staging');
config()->set('data-sync.archive.disk', 'archive');
config()->set('data-sync.syncs', [App\Syncs\ProductsSync::class]);
});Fake the source, staging, and archive disks and point the package's staging and archive config at the fakes. Registering the definition through config in the test — rather than relying on the application's own list — keeps each test independent.
The happy path
use App\Models\Product;
use ByRcsc\LaravelDataSync\Enums\RunStatus;
use ByRcsc\LaravelDataSync\Models\SyncRun;
it('imports products from a supplier csv', function (): void {
Storage::disk('incoming')->put('products/today.csv', <<<'CSV'
SKU,Description,Price
DS-1001,Anglepoise desk lamp,89.00
DS-1002,Mechanical keyboard,149.50
CSV);
$this->artisan('sync:run products --now')->assertSuccessful();
$run = SyncRun::query()->sole();
expect($run->status)->toBe(RunStatus::Completed)
->and($run->rows_created)->toBe(2)
->and(Product::query()->count())->toBe(2)
->and(Product::query()->where('sku', 'DS-1001')->value('price_cents'))->toBe(8900);
});--now keeps everything in the test process, which is what you want for
assertions about rows. Calling the manager directly works the same way and gives
you the report object:
use ByRcsc\LaravelDataSync\Facades\DataSync;
$report = DataSync::run('products');
expect($report->discovered)->toBe(1)
->and($report->runs[0]->created)->toBe(2);Asserting failures
use ByRcsc\LaravelDataSync\Models\SyncRowFailure;
it('records a row that fails validation and keeps going', function (): void {
Storage::disk('incoming')->put('products/today.csv', <<<'CSV'
SKU,Description,Price
DS-1001,Anglepoise desk lamp,89.00
,Mechanical keyboard,149.50
CSV);
DataSync::run('products');
$run = SyncRun::query()->sole();
$failure = SyncRowFailure::query()->sole();
expect($run->status)->toBe(RunStatus::CompletedWithErrors)
->and($run->rows_created)->toBe(1)
->and($run->rows_failed)->toBe(1)
->and($failure->row_number)->toBe(3)
->and($failure->errors)->toHaveKey('sku')
->and($failure->payload)->toBe([
'SKU' => '',
'Description' => 'Mechanical keyboard',
'Price' => '149.50',
]);
});Row numbers count as the reader counts them — with a header, the first data row is row 2. See row numbers.
A definition-level failure — a missing required column, an unmapped attribute — throws instead:
use ByRcsc\LaravelDataSync\Exceptions\SchemaMismatchException;
expect(fn () => DataSync::run('products'))
->toThrow(SchemaMismatchException::class);Every exception the package throws extends DataSyncException, so assert on
that when you care that the run failed rather than on how. See catching package
exceptions.
The checksum ledger in tests
Two traps come from file identity:
Identical content is the same file. Writing the same CSV twice in one test
produces one import and one skip, not two imports. Vary a value, or pass
force: true:
DataSync::run('products', force: true);A completed checksum survives within the test's transaction. If a test asserts a re-run, assert on the skip rather than expecting a second import:
$second = DataSync::run('products');
expect($second->discovered)->toBe(0)
->and($second->skipped)->toBe(1);Deferred files
minFileAge() compares against the file's modification time, so a file written
by a test is always brand new:
$this->travel(5)->minutes();
DataSync::run('products');Without travelling, a definition with minFileAge(minutes: 2) reports the file
as deferred and imports nothing — which is itself worth one test.
Queue dispatch
Assert that plain sync:run queues rather than writes:
use ByRcsc\LaravelDataSync\Jobs\ProcessFileJob;
use Illuminate\Support\Facades\Queue;
it('dispatches a file job on the configured queue', function (): void {
Queue::fake();
config()->set('data-sync.overrides.products.queue', ['connection' => 'redis', 'queue' => 'imports']);
Storage::disk('incoming')->put('products/today.csv', "SKU,Description,Price\nDS-1001,Anglepoise desk lamp,89.00\n");
$this->artisan('sync:run products')->assertSuccessful();
Queue::assertPushed(ProcessFileJob::class, fn (ProcessFileJob $job): bool =>
$job->connection === 'redis' && $job->queue === 'imports');
});To exercise the batched path end to end, run the queue synchronously:
config()->set('queue.default', 'sync');Chunk jobs then execute inline and the batch callbacks finalize the run, so the
same assertions as the --now path apply — with the batching code actually
covered.
Events
use ByRcsc\LaravelDataSync\Events\SyncCompleted;
use Illuminate\Support\Facades\Event;
Event::fake([SyncCompleted::class]);
DataSync::run('products');
Event::assertDispatched(SyncCompleted::class, fn (SyncCompleted $event): bool =>
$event->run->rows_created === 2 && ! $event->run->dry_run);Remember that dry runs dispatch events too — assert on dry_run when it
matters.
Dry runs
$report = DataSync::run('products', dryRun: true);
expect($report->runs[0]->created)->toBe(2)
->and(Product::query()->count())->toBe(0)
->and(Storage::disk('archive')->allFiles())->toBeEmpty();A dry run reports what it would do, writes nothing, and leaves the checksum unprocessed — so a follow-up real run imports the same file.
Transfers
Storage::fake('outgoing');
Storage::disk('incoming')->put('statements/STMT-000431-20260731.pdf', 'binary');
DataSync::run('statements');
expect(Storage::disk('outgoing')->exists('statements/000431/2026/07/STMT-000431-20260731.pdf'))
->toBeTrue();A transfer run reports 1 created, or 1 skipped when the destination already
holds the same checksum or the filename did not match.
In CI
php artisan sync:doctorRunning the doctor against your CI environment catches a definition that no
longer resolves, a mapped attribute that a migration removed, and a matchOn()
that lost its unique index — none of which a unit test on one definition would
notice. See health checks.
What to read next
- Running a sync for the programmatic API used above.
- File identity and runs for why re-running is a no-op.
- Troubleshooting for interpreting what a test reports.