When Useful, When Dangerous
Snapshot tests sound tempting: run once, save the result, done. In practice, though, they quickly turn into a maintenance burden if you do not exactly understand which scenarios they fit and how much discipline the team needs to apply.
Table of Contents
- 1. What snapshot tests really are
- 2. How snapshots work technically
- 3. Installation and the first snapshot
- 4. JSON snapshots for API responses
- 5. HTML snapshots for template output
- 6. When snapshots bring real value
- 7. When snapshots turn into a trap
- 8. Snapshot workflow in the team and CI
- 9. Snapshots vs. classic assertions compared
- 10. Summary
- 11. FAQ
1. What snapshot tests really are
A snapshot test is not an ordinary unit test that checks an expectation against an actual value. Instead, the test serializes the output of a function or renderer to a file on disk the first time it runs, the snapshot. On every subsequent test run, the current output is compared against this saved snapshot. If the output deviates, the test fails. That sounds simple, but it has far-reaching consequences for test design.
The basic promise: you do not have to write a separate assertion for every property of a complex output. For an API response with twenty fields, for HTML templates, or for serialized object graphs, that would mean dozens of lines of assertion code. A snapshot captures the complete result in a single file and shows exactly which line changed, if it fails. Snapshot testing genuinely keeps this promise, but only under certain conditions.
The decisive difference from classic assertions: with a snapshot test, the developer does not define the expectation explicitly. They implicitly accept that the current state of the code's output is the correct one. That is a fundamental decision that deserves careful thought, because a snapshot is only as good as the state it was created in.
2. How snapshots work technically
In PHP, snapshot testing is typically implemented via the spatie/phpunit-snapshot-assertions package. It provides a trait that is included in PHPUnit test classes and adds the method assertMatchesSnapshot() as well as type-safe variants like assertMatchesJsonSnapshot(), assertMatchesHtmlSnapshot(), and assertMatchesTextSnapshot(). Internally, the package serializes the given value into a format that is stored in a file under __snapshots__/ next to the test file.
The snapshot's file name is generated from the test class name and the test method. This enables a clear mapping: every test method has exactly one snapshot, or several if it is called in a loop, in which case they are numbered. Snapshots are versioned and end up in the git repository. This is important: changes to snapshots are visible and must be consciously committed. This creates traceability, but it also produces merge conflicts when multiple developers are working simultaneously on outputs that are captured by snapshots.
3. Installation and the first snapshot
Installation happens via Composer as a development dependency: composer require --dev spatie/phpunit-snapshot-assertions. The package supports PHPUnit 10 and 11 as well as PHP 8.1 and above. After installation, you include the MatchesSnapshots trait in the test class. The first time the test runs, the snapshot is created automatically and the test passes. On the second run, PHPUnit compares the output with the saved snapshot.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit;
use Mironsoft\Catalog\Service\ProductSerializer;
use PHPUnit\Framework\TestCase;
use Spatie\Snapshots\MatchesSnapshots;
final class ProductSerializerTest extends TestCase
{
use MatchesSnapshots;
private ProductSerializer $serializer;
protected function setUp(): void
{
$this->serializer = new ProductSerializer();
}
/** @test */
public function it_serializes_a_product_to_json(): void
{
$product = [
'sku' => 'TEST-001',
'name' => 'Test Product',
'price' => 49.99,
'stock' => 10,
];
// First run: creates __snapshots__/ProductSerializerTest__it_serializes_a_product_to_json__1.json
// Subsequent runs: compares against saved snapshot
$this->assertMatchesJsonSnapshot(
$this->serializer->serialize($product)
);
}
}
4. JSON snapshots for API responses
JSON snapshots are the most common and the most sensible use case for snapshot tests in PHP projects. REST APIs return complex JSON structures that can contain dozens of fields. Classic assertions would have to check every field value individually, which is tedious for structures that change often. A JSON snapshot captures the complete response and, in the failure case, shows exactly which field changed. That is particularly valuable when refactoring serialization logic: you see immediately whether a change has unintended side effects on the API output.
Important when using JSON snapshots: timestamps, UUIDs, and other dynamic fields must be normalized before the snapshot comparison. An API response containing a created_at field with the current timestamp will deviate on every test run and invalidate the snapshot. The solution: either remove the field from the response before the snapshot comparison, or structure the service so that timestamps are injected via an abstracted time source that is replaced by a fixed time in the test.
5. HTML snapshots for template output
HTML snapshots make sense for template rendering in Magento themes or other PHP-based templating systems. When a block class or a ViewModel renders a complex HTML structure, a snapshot can ensure that refactoring does not change the output. The method assertMatchesHtmlSnapshot() normalizes the HTML string before the comparison: whitespace and indentation are unified so that purely formatting-related differences do not trigger a snapshot failure.
<?php
declare(strict_types=1);
namespace Mironsoft\Theme\Test\Unit\Block;
use Mironsoft\Theme\Block\ProductBadge;
use PHPUnit\Framework\TestCase;
use Spatie\Snapshots\MatchesSnapshots;
final class ProductBadgeTest extends TestCase
{
use MatchesSnapshots;
/** @test */
public function it_renders_sale_badge_for_discounted_product(): void
{
$block = new ProductBadge();
$block->setData('original_price', 99.99);
$block->setData('final_price', 59.99);
// Normalize timestamps and session-dependent data before snapshot
$html = $block->toHtml();
$html = preg_replace('/data-timestamp="\d+"/', 'data-timestamp="0"', $html);
$this->assertMatchesHtmlSnapshot($html);
}
/** @test */
public function it_renders_nothing_for_full_price_product(): void
{
$block = new ProductBadge();
$block->setData('original_price', 99.99);
$block->setData('final_price', 99.99);
$this->assertMatchesHtmlSnapshot($block->toHtml());
}
}
6. When snapshots bring real value
Snapshot tests are most valuable in three concrete scenarios: first, with legacy code that has no tests and where the current behavior should be documented without manually asserting every aspect of the output. Second, with stable outputs that have many fields, API serializers, exports, PDF generation, where changes are rare and deliberate. Third, with regression tests after a bug, where the corrected output is confirmed as correct once and then frozen.
In all three scenarios the rule is: the developer must manually verify the initial snapshot. A snapshot created on top of a faulty state does not test anything meaningful. It merely gives assurance that the output stays as it is, even if the original behavior was wrong. This risk is particularly relevant when using snapshots for legacy code. Here, the snapshot should be combined with a manual review step in which a second developer checks the content of the generated snapshot file before it is committed.
7. When snapshots turn into a trap
Snapshot tests become dangerous when they are accepted uncritically. In a team that reflexively runs --update-snapshots on a snapshot failure without reading the diff, the tests exist only on paper. The test fails, the snapshot is updated, the commit goes through, without anyone having checked whether the output change was intentional. This pattern completely hollows out the value of the tests, but in practice it is frighteningly common.
Snapshots fundamentally do not fit outputs that legitimately differ on every call: timestamps, random values, session IDs, hash values derived from current data. Every dynamic component in the output either requires normalization or rules out snapshot tests. Equally problematic: snapshots for very simple outputs that would be covered just as well by two classic assertions. Anyone who creates a snapshot for a single string value builds up overhead without any corresponding benefit.
<?php
declare(strict_types=1);
namespace Mironsoft\Order\Test\Unit;
use Mironsoft\Order\Service\InvoiceRenderer;
use PHPUnit\Framework\TestCase;
use Spatie\Snapshots\MatchesSnapshots;
final class InvoiceRendererTest extends TestCase
{
use MatchesSnapshots;
/** @test */
public function it_renders_invoice_with_normalized_dynamic_fields(): void
{
$renderer = new InvoiceRenderer(
clock: new \DateTimeImmutable('2026-01-15 10:00:00') // fixed time
);
$invoice = $renderer->render([
'order_id' => 'ORD-2026-001',
'customer' => 'Max Mustermann',
'total' => 149.99,
'tax' => 23.96,
]);
// Remove truly unpredictable values before snapshot comparison
$normalized = preg_replace(
['/invoice_hash="[a-f0-9]+"/', '/generated_ms="\d+"/'],
['invoice_hash="HASH"', 'generated_ms="0"'],
$invoice
);
$this->assertMatchesHtmlSnapshot($normalized);
}
}
8. Snapshot workflow in the team and CI
A clear workflow is the prerequisite for snapshot tests to deliver their benefit rather than degenerating into a formality. The process should look like this: snapshots may only ever be updated locally, never automatically in the CI pipeline. In the pipeline, PHPUnit runs without the --update-snapshots flag. If a snapshot test fails, the build goes red, just like with any other test failure. Updating the snapshot is then a deliberate decision by the developer, who checks the diff and confirms the change as correct.
In code review, snapshot changes should always be commented on: why did the output change? Was that intentional? Does the new snapshot match the expected specification? Snapshot files are readable code artifacts and deserve the same attention as production code in the review. Teams that implement this consistently report that snapshot tests genuinely uncover regression bugs, because the changed output stands out in the review even though the developer did not consciously change it.
9. Snapshots vs. classic assertions compared
The choice between snapshot tests and classic assertions is not an either-or decision. In practice they complement each other. The comparison makes clear which approach fits which cases better.
| Criterion | Classic Assertions | Snapshot Tests | Recommendation |
|---|---|---|---|
| Output complexity | Simple values, few fields | Complex JSON/HTML structures | Snapshot for >10 fields |
| Dynamic fields | Easy to handle | Requires normalization | Classic for timestamps |
| Test readability | Clear, expectation in code | Expectation in external file | Classic for business logic |
| Maintenance effort | High with many fields | Low with stable outputs | Snapshot for stable serializers |
| Blind-update risk | Not possible | High without team discipline | Define workflow rules |
Mironsoft
PHP testing, PHPUnit strategies, and test automation
PHP tests that give real confidence?
We analyze existing test suites, identify gaps, and build a test strategy that meaningfully combines snapshot tests, unit tests, and integration tests, with CI integration and review processes.
Test audit
Check the existing test suite for coverage gaps and flawed test strategies
Snapshot setup
Introduce and document snapshot testing for API serializers and templates
CI workflow
Integrate snapshot validation into the CI pipeline and define the team workflow
10. Summary
Snapshot tests in PHP are a powerful tool for specific use cases: complex JSON outputs, HTML rendering, and regression tests for legacy code. They significantly reduce assertion code and make output changes visible through explicit diffs, but only if the initial snapshot is based on a correct state and the team has the discipline to review snapshot updates instead of blindly accepting them.
The biggest danger of snapshot tests is not technical in nature but organizational: a team that turns --update-snapshots into a routine loses the protection that tests are supposed to provide. The workflow must be clear: snapshots are updated locally on purpose, checked for content in review, and never automatically overwritten in CI. If these rules are followed, snapshot tests are a valuable addition to classic assertions, not a competitor but a complement.
PHPUnit Snapshots, the Essentials at a Glance
Use case
Complex JSON/HTML outputs, serializers, API responses, legacy code documentation. Not for dynamic fields without normalization.
Initialization
Always verify the first snapshot manually. A faulty initial snapshot only tests that the bug stays constant.
Team workflow
Updates only locally and deliberately. CI never with --update-snapshots. Review snapshot diffs for content in code review.
Normalization
Remove timestamps, UUIDs, and random values before the snapshot or replace them with fixed values. Use clock injection.