PHPUnit Snapshots in PHP Tests: When Useful, When Dangerous
AI generated
@test
assert
PHPUnit · Snapshot Testing · PHP · Testing
Snapshots in PHP Tests
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.

12 min read PHPUnit · spatie/snapshot-testing · JSON · HTML · Regression PHP 8.x · PHPUnit 10/11

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.

11. FAQ: PHPUnit Snapshots in PHP Tests

1What is a snapshot test?
A snapshot test serializes the output into a file the first time it runs. Subsequent test runs compare the current output against this saved state. Deviations trigger a test failure.
2Which package for PHPUnit snapshots?
spatie/phpunit-snapshot-assertions. Installation: composer require --dev spatie/phpunit-snapshot-assertions. Supports PHPUnit 10 and 11.
3How to update a snapshot?
With --update-snapshots on the PHPUnit call. Only locally after manually checking the diff. Never automatically in the CI pipeline.
4How to handle timestamps in snapshots?
Normalize via regex, remove them from the output, or use clock injection in the service to set a fixed timestamp in the test.
5Snapshot files into git?
Yes, mandatory. Snapshot files are part of the test definition and must be versioned. Changes are visible in git diff and are checked in code review.
6Snapshots for Magento templates?
Yes, for isolatable block classes and ViewModels that render HTML. Normalize dynamic and session-dependent fields beforehand.
7When should I avoid snapshots?
For simple outputs, heavily dynamic content without normalization, and when there is no review process for snapshot updates.
8Preventing blind updates in the team?
CI rules without --update-snapshots. Mandatory review for snapshot files. Commit message convention for snapshot updates. Team agreement on checking content.
9Compatible with PHPUnit 11?
Yes. spatie/phpunit-snapshot-assertions version 5.x supports PHPUnit 11. The API is largely compatible with earlier versions.
10Do snapshots replace classic unit tests?
No. Snapshots complement classic assertions. Business logic and edge cases are still better tested with explicit assertions. Snapshots are meant for complex, stable outputs.