spatie/phpunit-snapshot-assertions
Snapshot testing for PHPUnit: assert values match stored snapshots for JSON, strings, arrays, and more. Snapshots are created automatically on first run and compared on subsequent runs.
Snapshot testing flips the usual assertion model: instead of hand-writing expected values, you assert that output still matches what it produced the last time you looked at it. The package gives your PHPUnit test case a set of assertMatches*Snapshot() methods, writes the first result to disk, and fails your test when the output changes.
Install (dev only):
composer require --dev spatie/phpunit-snapshot-assertions
Wire the trait into a test class — or, more conveniently in a Laravel app, into the base Tests\TestCase so every test can reach the assertions:
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Spatie\Snapshots\MatchesSnapshots;
abstract class TestCase extends BaseTestCase
{
use MatchesSnapshots;
}
First use case — pin a JSON API response:
public function test_order_endpoint_returns_expected_payload(): void
{
$order = Order::factory()->create(['id' => 1]);
$this->getJson("/api/orders/{$order->id}")
->assertOk();
$this->assertMatchesJsonSnapshot(
$this->getJson("/api/orders/{$order->id}")->getContent()
);
}
Run it once. The snapshot does not exist yet, so the package writes it and marks the test incomplete:
1) OrderTest::test_order_endpoint_returns_expected_payload
Snapshot created for OrderTest__test_order_endpoint_returns_expected_payload__1
OK, but incomplete, skipped, or risky tests!
Where to look first: the __snapshots__ directory sitting next to your test file. For every assertion the package generates a file named {TestCase}__{test_name}__{increment}.{ext} — e.g. OrderTest__test_order_endpoint_returns_expected_payload__1.json. Read that file; it is your new expectation.
Run it again and the test passes with a real assertion. Change the endpoint and it fails with a readable diff between the stored snapshot and live output.
To accept a change, don't delete the file by hand — regenerate it:
./vendor/bin/update-snapshots tests/Feature/OrderTest.php
# or
UPDATE_SNAPSHOTS=true ./vendor/bin/phpunit
Then re-run the suite normally to confirm it's green, and commit the updated snapshot alongside the code change.
The highest-value use in a Laravel codebase: snapshot every resource response so field additions, renames, and type changes surface as diffs in review.
public function test_user_resource_shape(): void
{
$user = User::factory()->create(['id' => 42, 'created_at' => '2024-01-01 00:00:00']);
$this->assertMatchesJsonSnapshot(
$this->getJson("/api/users/{$user->id}")->getContent()
);
}
assertMatchesJsonSnapshot runs the payload through JsonDriver, which pretty-prints and compares semantically with assertJsonStringEqualsJsonString — so whitespace and key ordering don't produce false failures, but key presence and values do.
Great for catching accidental markup or wording regressions in transactional emails and shared components:
$this->assertMatchesHtmlSnapshot(
(string) (new OrderShipped($order))->render()
);
$this->assertMatchesObjectSnapshot($order->toArray());
$this->assertMatchesYamlSnapshot($config);
$this->assertMatchesTextSnapshot($command->output());
assertMatchesObjectSnapshot is the workhorse for "does this array/object still look like this" checks — cheap to add and often catches casts and nullability drift.
Auto-increment ids (__1, __2) make failures hard to read and reordering assertions reshuffles the files. Pass an explicit $id instead:
$this->assertMatchesJsonSnapshot($order->toJson(), 'order-created');
$this->assertMatchesJsonSnapshot($invoice->toJson(), 'invoice-created');
$this->assertMatchesTextSnapshot($output, 'cli-output');
Named snapshots are stored with an s- prefix internally so they never collide with (or advance) the auto-increment counter — you can mix both styles in one test.
Two flavours, pick deliberately:
assertMatchesFileHashSnapshot($path) — stores only a hash. Fast, tiny, but a failure tells you nothing about what changed.assertMatchesFileSnapshot($path) — stores the whole file and drops the failing artefact next to the snapshot for manual diffing (auto-deleted once the test passes). Use this for PDFs, images, exports — anything you'll want to eyeball.$this->assertMatchesImageSnapshot($path, 0.1); // requires spatie/pixelmatch-php
Passes if the image is near-identical to the stored one; the second argument is the tolerance (0–1). Needs composer require --dev spatie/pixelmatch-php.
Implement Spatie\Snapshots\Driver with three methods — serialize(), extension(), match() — when the built-ins don't suit your format (e.g. a normalised CSV export, a canonicalised query log):
class CsvDriver implements Driver
{
public function serialize($data): string { /* normalise + return string */ }
public function extension(): string { return 'csv'; }
public function match($expected, $actual) { Assert::assertSame($expected, $actual); }
}
$this->assertMatchesSnapshot($export->toCsv(), new CsvDriver());
Two scripts carry the whole workflow:
{
"scripts": {
"test": "phpunit",
"update-snapshots": "UPDATE_SNAPSHOTS=true vendor/bin/phpunit",
"test:ci": "CREATE_SNAPSHOTS=false vendor/bin/phpunit --fail-on-incomplete"
}
}
vendor/bin/update-snapshots accepts the same arguments as phpunit, so composer update-snapshots -- tests/Feature/OrderTest.php --filter=test_user targets narrowly. Locally you regenerate and commit; in CI, snapshots are read-only so a missing one is a hard failure rather than a silently created file.
Treat snapshot files as reviewed code, not build output. Keep __snapshots__ committed (never gitignored), and when a PR touches a snapshot, read the diff to confirm the change was intentional. That diff is the point of the package.
A newly created snapshot is an incomplete test, not a failure. By default PHPUnit reports "OK, but incomplete" and exits zero — so a CI run without CREATE_SNAPSHOTS=false can go green while quietly recording brand-new expectations. Always set CREATE_SNAPSHOTS=false in CI, and add --fail-on-incomplete to catch the local case too.
Parallel test runs need the environment variables, not CLI flags. With Paratest or php artisan test --parallel, set CREATE_SNAPSHOTS=false — concurrent workers can't safely create snapshot files, and a racing create produces confusing partial state. Same for UPDATE_SNAPSHOTS: don't run updates in parallel.
Use the wrapper binary, not -d --update-snapshots. The -d syntax abuses PHPUnit's php.ini flag and, since PHPUnit 12.5.12, emits a visible Failed to set "--update-snapshots=1" warning. vendor/bin/update-snapshots and the UPDATE_SNAPSHOTS / CREATE_SNAPSHOTS env vars are the supported paths (the legacy flag still works, noisily).
Snapshot ids are derived from class name + test name. Renaming a test method, renaming the test class, or moving it to another namespace orphans the old snapshot and creates a fresh one — which means a silent "incomplete" on first run. Delete stale files when you rename tests; the suite won't tell you they're dead.
Determinism is your responsibility. Snapshots fail on anything volatile: auto-increment IDs, now() timestamps, UUIDs, absolute file paths, Faker output, unordered collections. Pin what you can (Carbon::setTestNow(), explicit factory IDs, config() overrides, sorted arrays) and normalise the rest before asserting:
$json = preg_replace('/"created_at":".*?"/', '"created_at":"FIXED"', $response->getContent());
A snapshot that fails for non-reasons gets updated blindly — and then it's worthless.
JsonDriver only accepts strings. Pass $model->toJson() or $response->getContent(), not an array — you'll get a CantBeSerialized exception. If you want to snapshot an array/object, reach for assertMatchesObjectSnapshot.
Image threshold direction: higher values make the comparison more sensitive; the value must be between 0 and 1. And the assertion throws if spatie/pixelmatch-php isn't installed — an easy-to-miss dev dependency.
File snapshot failures leave artefacts behind. assertMatchesFileSnapshot writes the actual file next to the snapshot on failure and removes it on the next passing run. Don't commit those leftovers; scope your .gitignore to __snapshots__/*.failed.* style patterns if you use them heavily.
Windows line endings will cause spurious text/HTML diffs — normalise to \n in your driver or configure Git's core.autocrlf for the snapshots directory.
Relocate snapshots when you need to. Override getSnapshotDirectory() (e.g. to a snapshots folder or a path outside tests/) and getSnapshotId() if the default Test__method__1 naming clashes with your conventions — both are single-method overrides on the trait.
Snapshots complement assertions, they don't replace them. They're excellent at "nothing else changed" and poor at "this specific thing is correct" — a failure tells you that something differs, not why it matters. Keep behavioural assertions for contracts you actually care about, and use snapshots to catch the drift you didn't think to assert.
How can I help you explore Laravel packages today?