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
andsnapshot-tests-jaxb-jakarta
have been deprecated in favor of their respective drop-in replacementsnapshot-tests-json
,snapshot-tests-xml-legacy
andsnapshot-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:
<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>
testImplementation(platform("de.skuzzle.test:snapshot-tests-bom:1.11.0"))
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.
Module name | Description |
---|---|
JUnit5 extension. |
|
JUnit4 support via |
|
Core API and unstructured text comparison. |
|
JSON snapshot serialization using |
|
XML snapshot serialization using |
|
XML snapshot serialization using |
|
HTML snapshots using |
|
JUnit5 |
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)
or Exclude JUnit5 dependencies from snapshot format artifacts (gradle, groovy syntax)
|
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.
<dependency>
<groupId>de.skuzzle.test</groupId>
<artifactId>snapshot-tests-junit5</artifactId>
<version>1.11.0</version>
</dependency>
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
-
Implement test cases and add one ore more snapshot assertions as shown above.
-
When you execute these tests the first time, serialized snapshots of your test results will be persisted and the tests will fail.
-
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.
-
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
|
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:
@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
orJSON
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.
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.
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.
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.
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.
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:
snapshot.assertThat(actual)
.as(XmlSnapshot.xml())
.matchesSnapshotStructure();
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 theSnapshotSerializer
in place produces Unix line endings. Snapshot tests might fail if theStructuralAssertions
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:
@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:
-
Make the desired changes to your code under test. If you run the snapshot tests now they should fail.
-
Advise the framework to update the snapshot files with the latest actual results.
-
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).
-
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
|
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.
@Test
@ForceUpdateSnapshots
void testUpdateSnapshotWithAnnotation(Snapshot snapshot) {
final Person person = Person.determinePerson();
snapshot.assertThat(person)
.asText()
.matchesSnapshotText();
}
@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 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.
@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.
@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.
@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:
@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
:
@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 . |
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:
@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.
@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 Add context files to .gitignore
|
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: 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.
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.
@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 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.
@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. |
@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:
@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.
@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.
@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:
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:
-
How are objects serialized into a string of the respective format
-
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.
@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:
@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.
@Test
void testJSONSnapshotWithDefaults(Snapshot snapshot) {
final Person person = Person.determinePerson();
snapshot.assertThat(person)
.as(JsonSnapshot.json())
.matchesSnapshotStructure();
}
Advanced configuration options:
@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.
@Test
void testXMLnapshotWithDefaults(Snapshot snapshot) {
final Person person = Person.determinePerson();
snapshot.assertThat(person)
.as(XmlSnapshot.xml())
.matchesSnapshotStructure();
}
Advanced configuration options:
@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 sharedClock
instance that is replacable in tests and useLocalDateTime.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 implementStructralAssertions
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:
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:
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.