This is the user manual for the snapshot-tests extension for JUnit5 and JUnit4.

The documentation is still in beta phase and might not be exhaustive. If you are missing any information feel free to open a GitHub ticket and ask for help.

1. Introduction

This library allows to conveniently assert on the structure and contents of complex objects. It does so by storing a serialized version of the object during the first test execution and during subsequent test executions, compare the actual object against the stored snapshot.

Read more about snapshot testing in this accompanying blog post.

1.1. Versioning, Compatibility and Upgrading

This project strongly aims to provide a stable public API with little surprises for clients when they upgrade to a new version. We rely on the principles of semantic versioning and use @API Guardian to communicate the intention of all public classes/methods. Consequently you should not use any public types that are marked as INTERNAL.

The following principles apply:

  • Upgrading to a newer patch or minor version should never break client code or existing tests.

  • Upgrading to a newer major version can potentially break client code or tests.

  • All deprecations will be marked clearly in code and alternatives will be presented in the JavaDoc.

  • Deprecated code will be removed in accordance to the code’s previous API Guardian status.

It is possible that snapshot header information change even during a patch/minor update. This might cause your snapshot files to occur as modified files in your local SCM copy after upgrading to a newer framework version. This doesn’t pose a problem and you can safely update the files in your SCM.

1.2. Notable Changes in this Release

This documentation pertains to version 1.11.0 of snapshot-tests. The following notable changes have been made since the previous minor version:

  • The modules snapshot-tests-jackson, snapshot-tests-jaxb and snapshot-tests-jaxb-jakarta have been deprecated in favor of their respective drop-in replacement snapshot-tests-json, snapshot-tests-xml-legacy and snapshot-tests-xml.

The drop-ins come with a slightly different Automatic-Module-Name. If you are using JPMS you need to adjust the respective requires clause in your module-info.java.
  • The logic of creating and rendering diffs has been moved into its own separate module so that it can be reused on its own: diff-tool.

  • The framework can be advised to normalize line endings used in snapshot files. This is especially useful when using git and you do not care about which actual line ending is used in your snapshots. See Dealing With Line Breaks.

  • Allow to customize the diff format used when rendering failures during structure compare via @SnapshotTestOptions(diffFormat = DiffFormat.SPLIT)

2. Getting Started

2.1. Artifacts

The library comes with different modules that can be used and combined to fulfill different testing purposes. As such it is not uncommon that you want to include multiple modules in your project’s dependencies. We provide a BOM artifact for your convenience:

Maven BOM artifact
<dependencyManagement>
    <dependency>
        <groupId>de.skuzzle.test</groupId>
        <artifactId>snapshot-tests-bom</artifactId>
        <version>1.11.0</version>
        <type>pom</type>
        <scope>import</scope>
    </dependency>
</dependencyManagement>
Gradle BOM include
testImplementation(platform("de.skuzzle.test:snapshot-tests-bom:1.11.0"))
Gradle Spring-Boot dependency management plugin
dependencyManagement {
    imports {
        mavenBom 'de.skuzzle.test:snapshot-tests-bom:1.11.0'
    }
}

The following modules are managed in the previously mentioned BOM artifact. Follow the remaining documentation to learn how to properly combine them to employ snapshot testing in your own project.

Table 1. Available modules. Click the module’s name to view it on Maven Central.
Module name Description

snapshot-tests-junit5

JUnit5 extension.

snapshot-tests-junit4

JUnit4 support via @Rule and @ClassRule.

snapshot-tests-core

Core API and unstructured text comparison.

snapshot-tests-json

JSON snapshot serialization using jackson and structured comparison using jsonassert.

snapshot-tests-xml

XML snapshot serialization using jaxb with new jakarta dependencies and structured comparison using xmlunit.

snapshot-tests-xml-legacy

XML snapshot serialization using jaxb and structured comparison using xmlunit.

snapshot-tests-html

HTML snapshots using jsoup and structured comparison using xmlunit.

snapshot-tests-directory-params

JUnit5 ArgumentProvider implementations that allow to iterate over files/directories.

2.2. Choosing the Right Modules

In order to use snapshot-tests you have to answer two simple questions:

  • Which test framework are you using?

  • Which format do you want to use for serializing objects into persistable snapshot files?

Use With JUnit5

Historically JUnit5 is the preferred test framework and has always natively been supported. The preferred way of configuring the build is to add a dependency to snapshot-tests-junit5 and optionally add a dependency for your preferred snapshot format (i.e. like snapshot-tests-json).

Use With JUnit5 (Legacy)

The snapshot-tests-junit5 module has been introduced with version 1.8.0. Prior to that, you would either add a direct dependency to snapshot-tests-core or just use a single dependency to you preferred snapshot format which would pull in the -core module transitively. This setup still works but is discouraged. You will see a warning being printed to System.err stating the required migration steps.

Starting from version 2.0.0 this scenario will no longer be supported.

Use With JUnit4

JUnit4 support was introduced with version 1.8.0. Add a dependency to snapshot-tests-junit4 and optionally add a dependency for your preferred snapshot format like snapshot-tests-json.

In order to seamlessly support the JUnit5 legacy scenario described above, all snapshot format modules will still transitively pull in a JUnit5 dependency. Unfortunately this can only be fixed with the next major version.

Exclude JUnit5 dependencies from snapshot format artifacts (maven)
<dependency>
    <groupId>de.skuzzle.test</groupId>
    <artifactId>snapshot-tests-json</artifactId>
    <version>1.11.0</version>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>

or

Exclude JUnit5 dependencies from snapshot format artifacts (gradle, groovy syntax)
testImplementation('de.skuzzle.test:snapshot-tests-json:1.11.0') {
    exclude group: 'org.junit.jupiter', module: 'junit-jupiter-api'
}

2.3. Quick Start Code Example

This is a very simple quick start example of how to use this framework, using the -junit5 module and no particular snapshot format. Instead, it simply uses the toString() representation of the test result to create a persistable snapshot file.

Simple quick start example using maven and JUnit5
Add Maven dependency
<dependency>
    <groupId>de.skuzzle.test</groupId>
    <artifactId>snapshot-tests-junit5</artifactId>
    <version>1.11.0</version>
</dependency>
Write a simple snapshot test
import de.skuzzle.test.snapshots.Snapshot;
import de.skuzzle.test.snapshots.junit5.EnableSnapshotTests;

import org.junit.jupiter.api.Test;

@EnableSnapshotTests (1)
public class QuickstartTest {

    @Test
    void testCreateSnapshotAsText(Snapshot snapshot) throws Exception { (2)
        final Person actual = Person.determinePerson();
        snapshot.assertThat(actual)
                .asText() (3)
                .matchesSnapshotText(); (4)
    }
}
1 Enable the JUnit5 extension for your test class.
2 Declare a parameter of type Snapshot. It will be injected by the framework.
3 Choose a serialization format. Here we use the actual object’s toString() representation as snapshot format.
4 Perform the assertion by comparing the actual object with the persisted snapshot.

2.4. General Snapshot Testing Workflow

  1. Implement test cases and add one ore more snapshot assertions as shown above.

  2. When you execute these tests the first time, serialized snapshots of your test results will be persisted and the tests will fail.

  3. Execute the same tests again. Now, the framework will compare the test results against the persisted snapshots. If your code under test produces deterministic results, tests should now be green.

  4. Check in the persisted snapshots into your SCM.

The framework will fail all snapshot assertions when they are initially executed and there is no previous snapshot file to compare the current result against. This guards against accidently checking in broken or flaky tests into your SCM.

Assertion error after snapshots have been created initially
java.lang.AssertionError: Snapshots have been created the first time.
Run the test again and you should see it succeed.
If you find that your tests are flaky or continue to fail even after the initial test execution, take a look at the section about dealing with random values.

3. User Guide

3.1. Entrypoint

As shown in the Getting Started section, you need to enable snapshot tests capabilities for your test class by annotating it with @EnabledSnapshotTests. This registers a JUnit5 ArgumentResolver which takes care of injecting a Snapshot instance into your test methods. The Snapshot interface is the entrypoint for the assertion DSL.

If you are using JUnit4, you need to declare the SnapshotRule like this:

Enabling snapshot test capabilities in a JUnit4 test
@Rule
@ClassRule
public static final SnapshotRule snapshot = SnapshotRule.enableSnapshotTests();
You need to declare the rule as both @Rule and @ClassRule as there is no other possibility for the snapshot-tests framework to obtain all the relevant test execution information from the JUnit4 engine.

Besides the different way of enabling the snapshot testing capabilities there are no further differences between usage in JUnit4 and JUnit5. The Snapshot class is the main entrypoint for writing snapshot assertions.

3.2. Snapshot Serialization and Comparison

The framework needs to create a String representation of the test results in order to store it in a snapshot file. Creating such a String representation is referred to as serialization and this aspect is handled by implementations of the SnapshotSerializer interface.

To compare persisted snapshot data against actual test results, the framework serializes the actual test result as well and passes both Strings to an instance of StructuralAssertions.

Though serialization and comparison are handled by different components the aspects are still related. As StructuralAssertions solely work on the String representation it is important that the implementation understands the Strings that have been produced by the SnapshotSerializer (i.e. you should not mix a serializer that produces XML with an StructualAssertions instance that compares JSON).

The Snapshot DSL allows you to configure these aspects separately but also provides a convenient API for configuring the Serializer and Assertions at once via the StructuredData class.

The DSL distinguishes between just text and structured text snapshots. This distinction influences the way in which new test results can be compared to persisted snapshots:

  • If a snapshot uses a structured format such as XML or JSON the framework can make use of that structure during comparison to offer better error messages or to allow customization of the comparison details.

  • If a snapshot consists of just text the framework can only make a text comparison.

See the section about supported structured data formats to learn more about the different supported structured data formats.

Text-only Comparison

In order to present a meaningful assertion failed message the framework will always create a unified diff of the persisted snapshot and the serialized actual result.

Serialize without structured format and apply text-only comparison.
snapshot.assertThat(actual)
        .asText()
        .matchesSnapshotText(); (1)
1 .matchesSnapshotText() is the only available option when you choose no structured format using asText().

If the assertion fails because a difference has been found the framework will provide a rich failure report along with the assertion error’s stack trace.

Example text comparison failure.
org.opentest4j.AssertionFailedError: Stored snapshot doesn't match actual result.

Snapshot location:
    src\test\resources\de\skuzzle\test\snapshots\snippets\ComparisonTests_snapshots\testCreateSnapshotAsText_0.snapshot

Full unified diff of actual result and stored snapshot:
  7    - Name: <<Simon>>
     7 + Name: <<Peter>>
  8    - Surname: <<Taddiken>>
     8 + Surname: <<Pan>>
  9  9   Birthdate: 1777-01-12
 10 10   Address: Street: Gibtsnicht-Straße
 11 11   Number: 1337
 12 12   Zip: 4711
 13 13   City: Bielefeld
[...]

    at de.skuzzle.test.snapshots.snippets.ComparisonTests.testCreateSnapshotAsText(ComparisonTests.java:29)
    [...]

Most IDEs will additionally allow you to display the differences in a dedicated diff viewer. For example, if you double click the assertion failure in eclipse’s Failure Trace view you will be presented with the following view.

eclipse diff
Figure 1. The same assertion error as displayed in eclipse’s diff viewer.
Displaying multiple differences at once is one key strength of snapshot testing. It gives you the value of n assertions for only writing a single one.

Structural Comparison

When using a structured serialization format you can make use of advanced comparison features offered by the respective format.

Serialize to JSON and apply structural comparison.
snapshot.assertThat(actual)
        .as(JsonSnapshot.json()) (1)
        .matchesSnapshotStructure(); (2)
1 Choose to serialize test results as JSON (Requires the snapshot-tests-json module).
2 Choose .matchesSnapshotStructure() to trigger structural comparison.

When comparison fails, the framework enhances the failure message with additional information from the structural comparison.

Example structural comparison failure for JSON snapshots.
org.opentest4j.AssertionFailedError: name
Expected: Simon
     got: Peter
 ; surname
Expected: Taddiken
     got: Pan


Snapshot location:
    src\test\resources\de\skuzzle\test\snapshots\snippets\ComparisonTests_snapshots\testCreateSnapshotAsJson_0.snapshot

Full unified diff of actual result and stored snapshot:
  7  7   {
  8    -   "name" : ~~~~"Simon"~~~~,
     8 +   "name" : ~~~~"Peter"~~~~,
  9    -   "surname" : ~~~~"Taddiken"~~~~,
     9 +   "surname" : ~~~~"Pan"~~~~,
 10 10     "birthdate" : "1777-01-12",
 11 11     "address" : {
 12 12       "street" : "Gibtsnicht-Straße",
 13 13       "number" : "1337",
 14 14       "zipCode" : "4711",
[...]

    at de.skuzzle.test.snapshots.snippets.ComparisonTests.testCreateSnapshotAsJson(ComparisonTests.java:41)
    [...]

Likewise for XML serialization:

Serialize to XML and apply structural comparison.
snapshot.assertThat(actual)
        .as(XmlSnapshot.xml())
        .matchesSnapshotStructure();
Example structural comparison failure for XML snapshots.
org.opentest4j.AssertionFailedError:

Expecting:
 <control instance> and <test instance> to be identical
Expected text value 'Simon' but was 'Peter' - comparing <name ...>Simon</name> at /person[1]/name[1]/text()[1] to <name ...>Peter</name> at /person[1]/name[1]/text()[1]
Expected :<<name>Simon</name>>
Actual   :<<name>Peter</name>>


Snapshot location:
    src\test\resources\de\skuzzle\test\snapshots\snippets\ComparisonTests_snapshots\testCreateSnapshotAsXml_0.snapshot

Full unified diff of actual result and stored snapshot:
[...]
 12 12           <number>1337</number>
 13 13           <street>Gibtsnicht-Straße</street>
 14 14           <zipCode>4711</zipCode>
 15 15       </address>
 16 16       <birthdate/>
 17    -     ~~~~<name>Simon<~~~~/name>
    17 +     ~~~~<name>Peter<~~~~/name>
 18    -     ~~~~<surname>Taddiken<~~~~/surname>
    18 +     ~~~~<surname>Pan<~~~~/surname>
 19 19   </person>
    at de.skuzzle.test.snapshots.snippets.ComparisonTests.testCreateSnapshotAsXml(ComparisonTests.java:53)
    [...]

Dealing With Line Breaks

Most users probably don’t care about line endings during snapshot comparison. However, different line ending formats are likely to get in your way anyways when employing snapshot testing. That is because git has its very own idea of how to handle and convert different line endings. You might run into the following problems:

  • You checked out a project on windows with core.autocrlf=true but the SnapshotSerializer in place produces Unix line endings. Snapshot tests might fail if the StructuralAssertions instance in place puts significance to line breaks.

  • You force-updated some snapshots and all of them are marked as modified in git, even if there are no visible changes.

In case you don’t care about line endings you should advise the framework to normalize line endings before writing snapshot files. You can do that with the following annotation on either the test method or test class:

Normalize line endings according to local git config
@SnapshotTestOptions(normalizeLineEndings = SnapshotTestOptions.NormalizeLineEndings.GIT)

In case you do care about line endings and you are using git, you can use a .gitattributes file to signify the intended line ending to git during checkout.

Line endings are only normalized when producing the actual snapshot. Line endings are not normalized when reading the expected snapshot result from disc.

3.3. Updating Snapshots

One aspect that makes snapshot testing so powerful is that you can generate the expected output from your code under test itself. This removes the necessity to manually update test cases when the behavior of your code under test changes intentionally. The framework comes with a convenient approach for updating multiple snapshot files at once. You should stick to the following workflow:

  1. Make the desired changes to your code under test. If you run the snapshot tests now they should fail.

  2. Advise the framework to update the snapshot files with the latest actual results.

  3. Inspect the changes within your snapshot files to see whether they reflect your desired outcome (your SCM should mark the changed files as modified and should thus be able display a neat diff to the previous version).

  4. Remove the advice to update snapshots.

Whenever the framework overrides a snapshot with the new actual test result, the respective assertion will fail. As already stated in the Getting Started section, this is to prevent you from accidently checking in broken tests into your SCM.

Assertion error after forcefully updating snapshots
java.lang.AssertionError: Snapshots have been updated forcefully.
Remove 'updateSnapshots = true' attribute, @ForceUpdateSnapshots annotation, -DforceUpdateSnapshots JVM flag and calls to 'justUpdateSnapshot()' and run the tests again.

You can place the @ForceUpdateSnapshots annotation on either your test method or your whole test class to advise the framework to update the snapshots of all affected assertions.

Update the snapshots for all assertions in the same test method
@Test
@ForceUpdateSnapshots
void testUpdateSnapshotWithAnnotation(Snapshot snapshot) {
    final Person person = Person.determinePerson();
    snapshot.assertThat(person)
            .asText()
            .matchesSnapshotText();
}
Update the snapshots for all assertions in the same test class
@EnableSnapshotTests
@ForceUpdateSnapshots
class PersonTest {

    @Test
    void testUpdateSnapshotWithAnnotation(Snapshot snapshot) {
        final Person person = Person.determinePerson();
        snapshot.assertThat(person)
                .asText()
                .matchesSnapshotText();
    }

}
You might find it irritating that the @ForceUpdateSnapshots annotation is marked as deprecated. This is just a hack to make your IDE highlight its usages as it is intended to be only used temporarily.

Snapshots can also be updated globally by passing the -DforceUpdateSnapshots parameter to the JVM. This will update the snapshots of all tests executed within the JVM.

When using maven you can pass the option to the surefire-plugin on the command line using this little trick:

mvn clean verify -DargLine=-DforceUpdateSnapshots
Handling of the parameter is case-insensitive.

Finally you can replace the call to .matchesSnapshotText() or .matchesSnapshotStructure() of your assertion with .justUpdateSnapshot(). When executing the test the framework will simply override the existing snapshot for this assertion with the passed in actual result.

Change the assertion to update the snapshot file
@Test
void testUpdateSnapshot(Snapshot snapshot) {
    final Person person = Person.determinePerson();
    snapshot.assertThat(person)
            .asText()
            .justUpdateSnapshot(); (1)
}
1 Temporarily remove the actual assertion and replace it with justUpdateSnapshot().

3.4. Snapshot Files

By convention, snapshots of your test results are persisted as .snapshot files below src/test/resources. The framework will create sub directories according to your test class’s package and name. The snapshot file’s name will be derived from the respective test’s method name.

The snapshot folder resolution currently depends on the user.dir system property. As there is no reliable way of determining the current project location we have no better option than to resolve all paths against Path.of("."). In most cases this doesn’t pose a big problem as IDEs and build tools properly set the user.dir property when invoking tests. However it might be a problem if you invoke a build on the command line from a directory that differs from the actual project directory (related GitHub ticket: #3).

A snapshot file does not only include the serialized test result but also a small header section containing some meta information. These information are used by the framework to map the snapshot file to the test method and assertion from which it was created.

Changing the Snapshot Location

If you don not want to use the automatic way of determining the snapshot directory there are multiple options to manually specify the directory to which snapshot files will be written.

Change the directory of a snapshot via the DSL
@Test
void testChangeDirectoryViaDSL(Snapshot snapshot) {
    final Person person = Person.determinePerson();
    snapshot
            .in(Path.of("src", "test", "resources", "snapshots")) (1)
            .assertThat(person)
            .asText()
            .matchesSnapshotText();
}
1 Before calling assertThat(…​) you can use in(…​) to specify the snapshot directory for this assertion.
The path you provide here will not be resolved against the src/test/resources directory.

Another way is to use the @SnapshotDirectory annotation to provide a static snapshot directory.

Change the directory of a snapshot via annotation.
@EnableSnapshotTests
@SnapshotDirectory("snapshots") (1)
class ChangeDirectoryViaAnnotationTest {
    @Test
    void testChangeDirectoryViaAnnotation(Snapshot snapshot) {
        final Person person = Person.determinePerson();
        snapshot
                .assertThat(person)
                .asText()
                .matchesSnapshotText();
    }
}
1 Statically provide a snapshot directory relative to src/test/resources via @SnapshotDirectory.

Finally, you can provide an implementation of SnapshotDirectoryStrategy like this:

Change the directory of a snapshot via strategy.
@EnableSnapshotTests
@SnapshotDirectory(determinedBy = ResolveSnapshotDirectory.class) (1)
class ChangeDirectoryViaStrategyTest {
    @Test
    void testChangeDirectoryViaStrategy(Snapshot snapshot) {
        final Person person = Person.determinePerson();
        snapshot
                .assertThat(person)
                .asText()
                .matchesSnapshotText();
    }
}

public static class ResolveSnapshotDirectory implements SnapshotDirectoryStrategy { (2)

    @Override
    public Path determineSnapshotDirectory(Class<?> testClass, SnapshotDirectory directory)
            throws SnapshotException {
        return Path.of("src", "test", "resources", "snapshots");
    }

}
1 Point the framework to an implementation of SnapshotDirectoryStrategy.
2 Implement the strategy.

All three examples given will resolve to the same directory src/test/resources/snapshots. Instead of placing the @SnapshotDirectory annotation on each test method, you can also put it globally on the test class itself.

Changing the Snapshot File Name

In order to store persisted snapshots, the framework will determine a proper location and file name from the name of the respective test class and test method. By default, the snapshot will be named after the test method’s name. For each snapshot assertion within the same method, a consecutive number will be appended to the name (see also the section about Multiple Assertions in the Same Test Case).

The DSL offers a method to provide a custom name. Either by directly passing in a static string or by providing an implementation of SnapshotNaming:

Provide a static snapshot file name as string.
@Test
void testChangeSnapshotNameStatic(Snapshot snapshot) {
    final Person person = Person.determinePerson();
    snapshot
            .named("person") (1)
            .assertThat(person)
            .asText()
            .matchesSnapshotText();
}
1 Provide a static name. The resulting snapshot file will be named person.snapshot.
Provide a static snapshot file name via NamingStrategy.
@Test
void testChangeSnapshotNameStaticStrategy(Snapshot snapshot) {
    final Person person = Person.determinePerson();
    snapshot
            .namedAccordingTo(SnapshotNaming.constant("person")) (1)
            .assertThat(person)
            .asText()
            .matchesSnapshotText();
}
1 Provide an instance of SnapshotNaming. In this case the strategy will again just return the static string

If you use parameterized tests, the default automatic snapshot naming will determine the same snapshot file name for every parameterized test execution, thus overridding the snapshot file for each parameterization. See Parameterized Tests section for details.

Additional Context Files

The framework offers the possibility to not only generate the .snapshot file which will be used in the assertions but it can also generate additional contextual files.

You can advise the framework to always persist the actual results of the most recent test execution. In that case the framework will generate a sibling file with .snapshot_actual extension next to the regular .snapshot file. This file contains the serialized actual result of the most recent test execution as well as the same header information as the regular .snapshot file. This allows you to quickly replace the snapshot with the most recent test results without having to execute the test suite again. You can enable this feature via @SnapshotTestOptions annotation:

Advise the framework to always persist latest test results.
@Test
@SnapshotTestOptions(alwaysPersistActualResult = true)
void testAlwaysPersistActual(Snapshot snapshot) throws Exception {

}

In the same way you can advise the framework to also persist the most recent test results as a raw file which does not contain the snapshot header information. This wil lresult in another sibling file with .snapshot_raw extension.

Advise the framework to always persist latest test results without header information.
@Test
@SnapshotTestOptions(alwaysPersistRawResult = true)
void testAlwaysPersistActualRaw(Snapshot snapshot) throws Exception {

}

The annotation can as well be placed on the test class to change these options globally.

You should add the extensions of these context files to your .gitignore file. Unlike the main snapshot files, the context files are not meant to be checked into the SCM.

Add context files to .gitignore
*.snapshot_actual
*.snapshot_raw

Orphaned Snapshots

Snapshot files can become orphans if you rename or delete test methods/classes. It is desirable to remove orphaned snapshots as they will otherwise just add clutter to your repository. The framework comes with a sophisticated approach for detecting and removing such orphaned files.

By default, the framework will just log a warning like this to the console about detected orphan files:

Warning about detected orphan file
WARNING: Found orphaned snapshot file. Run with @DeleteOrphanedSnapshots annotation to remove: testThatHasBeenDeleted_0.snapshot in src/test/resources/de/skuzzle/test/snapshots/impl/OrphanedSnapshotDetectionTest$TestCase_snapshots

There are a number of situations in which a snapshot can become an orphan:

  • The test method containing the snapshot assertion has been removed/renamed.

  • The test class containing the test with the snapshot assertion has been removed/renamed.

  • The test is no longer a snapshot test.

  • Another snapshot assertion has been added/removed to/from the same test method.

Info about orphan detection

Orphan detection is not a straight forward task and quite a lot complexity within the implementation of this library comes from solving this task.

For example, just by examining a snapshot file, the framework can’t reliably determine whether the test still contains the snapshot assertion statement that created the file in the first place.

On the other hand there are some criteria that can be examined statically. As the snapshot file’s header information contain the full qualified name of the test class and the name of the test method, it is quite easy to determine whether those still exists or not.

Consequently, orphan detection implementation is separated into two phases:

  1. During dynamic orphan detection, the framework collects the actual snapshot results of all executed tests. Then it checks all existing snapshot files for whether there exists a matching result.

  2. During static orphan detection, the framework examines the header information of all snapshot files to determine whether their originating class/test still exist.

This approach has the drawback that orphans can be reported falsely if you only run a subset of your test suite.

You can advise the framework to automatically delete the detected orphaned files during a test run. This works similar to updating snapshot files by either adding an annotation or by passing a flag to the JVM.

Delete detected orphans during test execution
@EnableSnapshotTests
@DeleteOrphanedSnapshots (1)
public class DeleteOrphansTest {

    // ...
}
1 Temporarily add the @DeleteOrphanedSnapshots annotations during test execution.
You might find it irritating that the @DeleteOrphanedSnapshots annotation is marked as deprecated. This is just a hack to make your IDE highlight its usages as it is intended to be only used temporarily.

You can also pass the flag -DdeleteOrphanedSnapshots to the JVM.

When using maven you can pass the option to the surefire-plugin on the command line using this little trick:

mvn clean verify -DargLine=-DdeleteOrphanedSnapshots
Handling of the parameter is case insensitive.

3.5. Multiple Assertions in the Same Test Case

You can have multiple assertions in the same test case. The framework will assign each snapshot file a consecutive number.

Use multiple assertions in the same Test Case
@Test
void testWithMultipleAssertions(Snapshot snapshot) throws Exception {
    final Person person1 = person();
    final Person person2 = person();
    snapshot.assertThat(person1).asText().matchesSnapshotText();
    snapshot.assertThat(person2).asText().matchesSnapshotText();
}
As the snapshot files are numbered in order of their assertion’s execution, you can not easily reorder the assertions within the test case. If you do, the framework would not be able to map the assertion to the correct snapshot file.
You can specify an explicit snapshot name for each assertion to not rely on assertion ordering. See the section about snapshot naming to learn more about snapshot naming.
As is the case with normal tests you should avoid having multiple/many/too many assertions in a single test case.

3.6. Disabling Snapshot Assertions

The framework offers a possibility to gracefully disable a snapshot assertion. This means that the assertion will still be registered with the framework in order to not confuse the consecutive numbering. It won’t do any comparison though and thus won’t cause a test failure.

You can not simply comment the assertion or remove the terminal DSL operation in order to disabled the assertion. Commenting the assertion would confuse the numbering and removing the terminal operation is not allowed and will lead to an exception.
If a test case contains only disabled and successfully executed snapshot assertions, the framework will properly report the test case as skipped to the test framework by throwing the appropriate assumption failure.
Different options to (temporarily) disable a snapshot assertion.
@Test
void testWithDisabledAssertions(Snapshot snapshot) throws Exception {
    final Person person1 = person();
    final Person person2 = person();
    final Person person3 = person();
    snapshot.assertThat(person1).asText().disabled(); (1)
    snapshot.assertThat(person2).asText().disabledBecause("See bug reference TBD-1337"); (2)
    snapshot.assertThat(person3).asText().matchesSnapshotText(); (3)
}
1 Simply disable the assertion
2 Disable the assertion and provide an informative reason why it is disabled
3 Execute another assertion which will correctly get the number 3 assigned

3.7. Parameterized Tests

Snapshot tests work well together with JUnit5’s @ParameterizedTest but only if you take care of proper snapshot naming yourself like in this snippet:

Advise the framework to always persist latest test results without header information.
@ParameterizedTest
@ValueSource(strings = { "string1", "string2" })
void testParameterized(String param, Snapshot snapshot) {

    snapshot.namedAccordingTo(SnapshotNaming.withParameters(param))
            .assertThat(param)
            .asText()
            .matchesSnapshotText();
}

This will make each parameter’s value part of the generated snapshot’s file name.

Otherwise, when using the default naming strategy, the framework would choose the same snapshot name for every parameterized execution.

The behavior of choosing the same snapshot name for each parameter could actually be desirable if you want to test that your code produces the exact same result for different parameters.

Check out the SnapshotNaming interface for more options regarding snapshot naming.

Snapshot instances are injected via a JUnit5 ParameterResolver. As such, if you want to mix them with arguments provided by an ArgumentResolver, the Snapshot parameter of the test method must be declared after the argument parameters.

3.8. Nested Tests

@Nested test classes are a nice JUnit5 feature to structure tests in a hierarchical fashion. When you put @EnableSnapshotTests on the top level test class, snapshot tests will be enabled automatically for each @Nested class. All options that are normally resolved from annotations on the test class are resolved by first querying the nested class itself and then all of its enclosing parents. The first encountered annotation in that chain wins. Also, each nested test class will by default be assigned an own snapshot directory. You could change this by configuring the directory on a common ancestor.

Use @Nested to hierarchically structure JUnit5 tests
@EnableSnapshotTests
public class NestedTests {

    @Test
    void topLevelSnapshotTest(Snapshot snapshot) {
        // ...
    }

    @Nested
    @SnapshotDirectory("common-directory")
    class NestedInnerTest {

        @Nested
        @SnapshotTestOptions(alwaysPersistActualResult = true)
        class SecondLevelInnerClass {

            @Test
            void someSnapshotTest(Snapshot snapshot) {
                // ...
            }
        }

        @Nested
        class AnotherSecondLevelInnerClass {

            @Test
            void anotherSnapshotTest(Snapshot snapshot) {
                // ...
            }
        }
    }

}

3.9. Soft Assertions (deprecated)

Soft assertions are a deprecated feature and will be removed with version 2.0.0.

As learned in the previous section, you can easily have multiple snapshot assertions within the same test method. Naturally, if the first assertion fails, all subsequent assertions will not be executed, thus potentially hiding further problems.

However, there is an option to enable soft assertions. That will advice the framework to collect all assertion failures and report them in a single stack trace at the end.

Enable soft assertions
@EnableSnapshotTests(softAssertions = true)
class SomeTest {
    // ...
}
Soft assertions are currently only supported on a per-class basis.

3.10. Supported Structured Data Formats

This library comes with out-of-the-box support for multiple structured data formats. The respective artifacts were already mentioned in the artifacts section in the beginning.

This section explains in detail how to work with each of the different structured data formats and how to fine-tune their comparison behavior.

In general, each structured data format comes with a public builder class that can be used to configure the comparison. This builder can be passed to the DSL when configuring the snapshot assertion. In pseudo-code:

Pseudo code that shows how to configure the structured data format for a snapshot assertion
snapshot
    .assertThat(...)
    .as(DataFormatSnapshot.dataFormat() (1)
        .withOption() (2)
        .withAnotherOption(true)
     )
    .matchesSnapshotStructure();
1 By convention, structured data builders are named <Format>Snapshot and offer a static method <format>()
2 The builders for different formats may offer different customization options

This configures both a SnapshotSerializer and a StructuredAssertions instance for the assertion. Doing so we are effectively configuring two aspects at once:

  1. How are objects serialized into a string of the respective format

  2. How are two serialized strings compared/checked for equality

The DSL also offers to configure these aspects individually but in general it is more convenient to configure them at once.
If you have multiple tests sharing the same structured data configuration, you can carelessly define a static constant with the result of DataFormatSnapshot.dataFormat()…​ and reuse that.

Text

Though it is not really a structured format, text comparison internally is implemented as a structured data format as well. It is special in that regards that it is natively built into the library’s core and the DSL offers a convenience method for text-only comparison. Text snapshots always use the toString() representation of the object under test to produce the snapshot string.

Native DSL support for text snapshots
@Test
void testNativeTextSnapshot(Snapshot snapshot) {
    final Object someObject = "text";
    snapshot.assertThat(someObject)
            .asText()
            .matchesSnapshotText();
}

If you want to customize the comparison behavior, you need to explicitly configure a TextSnapshot like so:

Customize comparison behavior for text snapshots
@Test
void testCustomizedTextSnapshot(Snapshot snapshot) {
    final Object someObject = "text";
    snapshot.assertThat(someObject)
            .as(TextSnapshot.text()
                    .withContextLines(10) (1)
                    .withIgnoreWhitespaces(true) (2)
                    .withDiffFormat(DiffFormat.SPLIT)) (3)
            .matchesSnapshotText(); (4)
}
1 Configure the amount of context lines that are printed around a changed line in a diff
2 Configure whether whitespaces are significant during comparison
3 Configure how to render the diffs
4 Trigger the comparison. In the case of TextSnapshot it actually doesn’t matter whether you call matchesSnapshotStructure() or matchesSnapshotText() at this point

There is another delicacy regarding the native nature of text snapshots: their StructuralAssertions implementation is internally used to attach a neat unified diff to the comparison result of any other StructualAssertions implementation. This leads to all assertion failures consisting of both: details about the structural comparsion and a unified diff of the compared snapshot strings.

JSON

JSON support relies on two 3rd party libraries: we use the popular jackson library for serializing objects to json. Then we use skyscreamer/JSONassert to perform the structural comparison.

Simple JSON snapshot
@Test
void testJSONSnapshotWithDefaults(Snapshot snapshot) {
    final Person person = Person.determinePerson();
    snapshot.assertThat(person)
            .as(JsonSnapshot.json())
            .matchesSnapshotStructure();
}

Advanced configuration options:

Example of advanced JSON snapshot configuration
@Test
void testJSONSnapshotWithCustomOptions(Snapshot snapshot) {
    final Person person = Person.determinePerson();
    final ObjectMapper customObjectMapper = new ObjectMapper();

    snapshot.assertThat(person)
            .as(JsonSnapshot.json(customObjectMapper) (1)
                    .withCompareMode(CompareMode.NON_EXTENSIBLE) (2)
                    .withComparisonRules(rules -> rules
                            .pathAt("address.city").ignore() (3)
                            .pathAt("date").mustMatch(Pattern.compile("\\d{4}-\\d{2}-\\d{2}"))) (4)
                    .configure(mapper -> mapper
                            .registerModule(new JavaTimeModule()))) (5)
            .matchesSnapshotStructure();
}
1 Use a custom ObjectMapper instead of the built-in one
2 Define strictness of the comparison
3 Ignore a certain json path during comparison
4 Compare a certain json path to a regular expression rather than the actual snapshot text
5 Apply additional configuration to the use ObjectMapper
The built in ObjectMapper comes with preconfigured support for the "new" Java DateTime types.

The structural comparison offers some advantages over pure text comparison: As shown in the example, knowledge over the structure allows us for example to exclude certain parts from the comparison or to apply custom rules to certain parts of the snapshot. These options can help when dealing with random values in test results.

XML

XML snapshots come in two flavors: you can either use the jaxb-classic implementation based on javax.xml namespaces or the the jakarta based implementation based on jakarta.xml namespaces. In either case, the API is identical and you can drop in one artifact as replacement for the other.

While serialization relies on JAXB, comparison is implemented using XMLUnit.

Simple XML snapshot
@Test
void testXMLnapshotWithDefaults(Snapshot snapshot) {
    final Person person = Person.determinePerson();
    snapshot.assertThat(person)
            .as(XmlSnapshot.xml())
            .matchesSnapshotStructure();
}

Advanced configuration options:

Example of advanced XML snapshot configuration
@Test
void testXMLSnapshotWithCustomOptions(Snapshot snapshot) throws JAXBException {
    final Person person = Person.determinePerson();
    final JAXBContext customJaxbContext = JAXBContext.newInstance(Person.class);

    snapshot.assertThat(person)
            .as(XmlSnapshot.xml()
                    .withJAXBContext(customJaxbContext) (1)
                    .withMarshaller(ctx -> ctx.createMarshaller()) (2)
                    .withEnableXPathDebugging(true) (3)
                    .withXPathNamespaceContext(Map.of("ns1", "foo:1", "ns2", "foo:2")) (4)
                    .withComparisonRules(rules -> rules (5)
                            .pathAt("/person/address/city/text()").ignore()
                            .pathAt("/person/date/text()").mustMatch(compile("\\d{4}-\\d{2}-\\d{2}"))
                            .pathAt("/ns1:root/ns2:child/text()").ignore())
                    .compareUsing(compareAssert -> compareAssert.areSimilar()) (6)
                    .withPrettyPrintStringXml(true) (7)

            )
            .matchesSnapshotStructure();
}
1 Use a custom JAXBContext instead of the built-in one
2 Define a function of how to obtain the Marshaller from the JAXBContext
3 Enable XPath debugging. Will print helpful information about matched notes when using custom rules (see below)
4 Define the namespace mapping that can be used in XPaths
5 Define comparison rules based on xpath path expressions
6 Low level access to the xmlunit-assertj API to customize the comparison
7 Pretty print XML String before storing it as snapshot. Only applies when the input to snapshot.assertThat is already a XMl String rather than an Object that needs to be serialized by JAXB.

HTML

HTML snapshot support is also built on XMLUnit for structural comparison. We use JSoup for sanitizing and formatting HTML strings before persisting them as snapshots.

Because HTML snapshots are compared via XMLUnit just like XML snapshots the public API of HtmlSnapshot is a subset of the API of XMLSnapshot.

4. Advanced topics

4.1. Dealing With Random Values in Test Results

A common source of problems are random values within the snapshot data such as dates or generated ids. Generally, you should design your code up front so that such randomness can easily be mocked away. For example:

  • Instead of using LocalDateTime.now() make your code use a shared Clock instance that is replacable in tests and use LocalDateTime.now(clock)

  • More generally put: If your code uses random values in any place, consider to use a strategy interface instead which can be replaced with a deterministic mock during testing.

  • As a last resort, you can implement some normalization. Either post-process your actual test result before taking the snapshot or implement a SnapshotSerializer which does the normalization. You could also implement StructralAssertions in a way that it ignores such random values during comparison.

New The latest version of this library comes with a very simple (and experimental) abstraction for customizing the structural comparison. You can use json-path resp. xpath expressions to customize the comparison on a per-node basis.

XML example:

Define custom comparison rules for XML or HTML snapshots based on xpath expressions.
snapshot.assertThat(someObjext)
        .as(XmlSnapshot.xml()
                .withComparisonRules(rules -> rules
                        .pathAt("/person/address/city/text()").ignore()
                        .pathAt("/person/date/text()").mustMatch(Pattern.compile("\\d{4}-\\d{2}-\\d{2}"))))
        .matchesSnapshotStructure()

JSON example:

Define custom comparison rules for JSON snapshots based on json-path expressions.
snapshot.assertThat(someObjext)
        .as(JsonSnapshot.json()
                .withComparisonRules(rules -> rules
                        .pathAt("address.city").ignore()
                        .pathAt("date").mustMatch(Pattern.compile("\\d{4}-\\d{2}-\\d{2}"))))
        .matchesSnapshotStructure();

4.2. Driving Snapshot Tests From Files

Using stored files as input for your snapshot tests is a great way to quickly create new test cases. You can find details in this blog post.