diff --git a/docs/extensions.adoc b/docs/extensions.adoc
index 735baf0fb3..0fdd8a3964 100644
--- a/docs/extensions.adoc
+++ b/docs/extensions.adoc
@@ -775,12 +775,19 @@ The `rootPath` directory is required and the `@Snapshot` extension will throw an
Snapshots can be updated when setting the `spock.snapshots.updateSnapshots` system property to `true`, or via the config file.
+You can configure the extension to store the actual value in case of a mismatch by setting the `spock.snapshots.writeActual` system property to `true`, or via the config file.
+If enabled the extension will store the result in a file next to the original one with an additional `.actual` extension.
+The `.actual` file will automatically be deleted on the next successful run, as long as the feature is still enabled.
+This option is useful, when the snapshot is too large or complex to analyze with the built-in reporting.
+
.Example snapshot config
[source,groovy]
----
snapshots {
rootPath = Paths.get("src/test/resources")
updateSnapshots = System.getenv("UPDATE_SNAPSHOTS") == "true"
+ writeActualSnapshotOnMismatch = !System.getenv("CI")
+ defaultExtension = 'snap.groovy'
}
----
diff --git a/spock-core/src/main/java/org/spockframework/runtime/extension/builtin/SnapshotConfig.java b/spock-core/src/main/java/org/spockframework/runtime/extension/builtin/SnapshotConfig.java
index a96c1ca81f..dcf575c45a 100644
--- a/spock-core/src/main/java/org/spockframework/runtime/extension/builtin/SnapshotConfig.java
+++ b/spock-core/src/main/java/org/spockframework/runtime/extension/builtin/SnapshotConfig.java
@@ -40,6 +40,12 @@ public class SnapshotConfig {
* Instructs the {@link spock.lang.Snapshotter} to update the snapshot instead of failing on a mismatch or missing snapshot.
*/
public boolean updateSnapshots = Boolean.getBoolean("spock.snapshots.updateSnapshots");
+ /**
+ * Controls whether the {@link spock.lang.Snapshotter} should write actual value next to the snapshot file with the '.actual' extension.
+ *
+ * The file will be deleted upon a successful match.
+ */
+ public boolean writeActualSnapshotOnMismatch = Boolean.getBoolean("spock.snapshots.writeActual");
/**
* The default extension to use.
*/
diff --git a/spock-core/src/main/java/org/spockframework/runtime/extension/builtin/SnapshotExtension.java b/spock-core/src/main/java/org/spockframework/runtime/extension/builtin/SnapshotExtension.java
index 14e8bd0e9a..72715c1e72 100644
--- a/spock-core/src/main/java/org/spockframework/runtime/extension/builtin/SnapshotExtension.java
+++ b/spock-core/src/main/java/org/spockframework/runtime/extension/builtin/SnapshotExtension.java
@@ -67,6 +67,7 @@ private Snapshotter createSnapshotter(IMethodInvocation invocation, Class> typ
invocation.getMethod().getIteration(),
config.rootPath,
config.updateSnapshots,
+ config.writeActualSnapshotOnMismatch,
extension,
Charset.forName(annotation.charset()));
Checks.checkArgument(Snapshotter.class.isAssignableFrom(type), () -> "Target must be of type spock.lang.Snapshotter or a valid subtype");
diff --git a/spock-core/src/main/java/spock/lang/Snapshotter.java b/spock-core/src/main/java/spock/lang/Snapshotter.java
index 3e6ff8e79b..fab19ee6ab 100644
--- a/spock-core/src/main/java/spock/lang/Snapshotter.java
+++ b/spock-core/src/main/java/spock/lang/Snapshotter.java
@@ -73,6 +73,17 @@ protected void saveSnapshot(String snapshotId, String value) {
snapshotStore.saveSnapshot(snapshotId, wrapper.wrap(value));
}
+ protected void saveActual(String snapshotId, String value) {
+ Checks.notNull(snapshotId, () -> "snapshotId is null");
+ Checks.notNull(value, () -> "value is null");
+ snapshotStore.saveActual(snapshotId, wrapper.wrap(value));
+ }
+
+ protected void deleteActual(String snapshotId) {
+ Checks.notNull(snapshotId, () -> "snapshotId is null");
+ snapshotStore.deleteActual(snapshotId);
+ }
+
/**
* Declares a {@link Wrapper} for wrapping and unwrapping of the reference value.
*
@@ -171,10 +182,16 @@ public void matchesSnapshot(String snapshotId, BiConsumer snapsh
String snapshotValue = loadSnapshot(snapshotId);
try {
snapshotMatcher.accept(snapshotValue, value);
+ if (snapshotStore.isWriteActualContents()) {
+ deleteActual(snapshotId);
+ }
} catch (AssertionError e) {
if (snapshotStore.isUpdateSnapshots()) {
saveSnapshot(snapshotId, value);
} else {
+ if (snapshotStore.isWriteActualContents()) {
+ saveActual(snapshotId, value);
+ }
throw e;
}
}
@@ -247,13 +264,15 @@ public String unwrap(String string) {
public static final class Store {
private final IterationInfo iterationInfo;
private final boolean updateSnapshots;
+ private final boolean writeActualContents;
private final String extension;
private final Charset charset;
private final Path specPath;
- public Store(IterationInfo iterationInfo, Path rootPath, boolean updateSnapshots, String extension, Charset charset) {
+ public Store(IterationInfo iterationInfo, Path rootPath, boolean updateSnapshots, boolean writeActualContents, String extension, Charset charset) {
this.iterationInfo = Assert.notNull(iterationInfo);
this.updateSnapshots = Assert.notNull(updateSnapshots);
+ this.writeActualContents = writeActualContents;
this.extension = Assert.notNull(extension);
this.charset = Assert.notNull(charset);
@@ -311,8 +330,35 @@ public void saveSnapshot(String snapshotId, String value) {
}
}
+ public void saveActual(String snapshotId, String value) {
+ Assert.notNull(snapshotId);
+ Assert.notNull(value);
+ try {
+ Path snapshotPath = specPath.resolve(calculateSafeUniqueName(extension + ".actual", iterationInfo, snapshotId));
+ Files.createDirectories(specPath);
+ IoUtil.writeText(snapshotPath, value, charset);
+ System.err.println("Snapshot actual value has been saved to: " + snapshotPath.toAbsolutePath());
+ } catch (IOException e) {
+ throw new UncheckedIOException("Could not create directories", e);
+ }
+ }
+
+ public void deleteActual(String snapshotId) {
+ Assert.notNull(snapshotId);
+ try {
+ Path snapshotPath = specPath.resolve(calculateSafeUniqueName(extension + ".actual", iterationInfo, snapshotId));
+ Files.deleteIfExists(snapshotPath);
+ } catch (IOException e) {
+ throw new UncheckedIOException("Could not delete file", e);
+ }
+ }
+
public boolean isUpdateSnapshots() {
return updateSnapshots;
}
+
+ public boolean isWriteActualContents() {
+ return writeActualContents;
+ }
}
}
diff --git a/spock-specs/src/test/groovy/org/spockframework/smoke/extension/SnapshotterSpec.groovy b/spock-specs/src/test/groovy/org/spockframework/smoke/extension/SnapshotterSpec.groovy
index b3664d724d..719eaface2 100644
--- a/spock-specs/src/test/groovy/org/spockframework/smoke/extension/SnapshotterSpec.groovy
+++ b/spock-specs/src/test/groovy/org/spockframework/smoke/extension/SnapshotterSpec.groovy
@@ -15,17 +15,20 @@
package org.spockframework.smoke.extension
+import org.spockframework.runtime.ConditionNotSatisfiedError
+import org.spockframework.runtime.IStandardStreamsListener
+import org.spockframework.runtime.StandardStreamsCapturer
import org.spockframework.runtime.model.FeatureInfo
import org.spockframework.runtime.model.IterationInfo
import org.spockframework.runtime.model.MethodInfo
-import spock.lang.Snapshotter
-import spock.lang.Specification
-import spock.lang.TempDir
-import spock.lang.Unroll
+import org.spockframework.runtime.model.parallel.Resources
+import spock.lang.*
import java.lang.reflect.Method
import java.nio.charset.StandardCharsets
+import java.nio.file.Files
import java.nio.file.Path
+import java.nio.file.Paths
class SnapshotterSpec extends Specification {
@TempDir
@@ -33,7 +36,7 @@ class SnapshotterSpec extends Specification {
def "Snapshotter.Store can store and load correctly (#scenario)"(String value) {
given:
- def updatingStore = new Snapshotter.Store(specificationContext.currentIteration, tmpDir, true, 'txt', StandardCharsets.UTF_8)
+ def updatingStore = new Snapshotter.Store(specificationContext.currentIteration, tmpDir, true, false, 'txt', StandardCharsets.UTF_8)
when:
updatingStore.saveSnapshot("", value)
@@ -93,4 +96,51 @@ class SnapshotterSpec extends Specification {
IllegalArgumentException e = thrown()
e.message.startsWith("'snapshotId' is too long, only 100 characters are allowed, but was 101: aaaaa")
}
+
+ @ResourceLock(Resources.SYSTEM_OUT)
+ @ResourceLock(Resources.SYSTEM_ERR)
+ def "snapshotter stores .actual file on mismatch and deletes it on a successful match"() {
+ given:
+ String actualPath
+ def updatingSnapshotter = new Snapshotter(new Snapshotter.Store(specificationContext.currentIteration, tmpDir, true, false, 'txt', StandardCharsets.UTF_8))
+ def verifyingSnapshotter = new Snapshotter(new Snapshotter.Store(specificationContext.currentIteration, tmpDir, false, true, 'txt', StandardCharsets.UTF_8))
+
+ and:
+ def listener = Mock(IStandardStreamsListener)
+ def capturer = new StandardStreamsCapturer().tap {
+ addStandardStreamsListener(listener)
+ start()
+ muteStandardStreams()
+ }
+
+ and:
+ updatingSnapshotter.assertThat("reference").matchesSnapshot(snapshotId)
+
+ when:
+ verifyingSnapshotter.assertThat("different").matchesSnapshot(snapshotId)
+
+ then:
+ thrown(ConditionNotSatisfiedError)
+ 1 * listener.standardErr(_) >> { String msg ->
+ actualPath = msg.find(/Snapshot actual value has been saved to: (.*)/) { it[1] }
+ }
+
+ and:
+ actualPath != null
+ def path = Paths.get(actualPath)
+ Files.isRegularFile(path)
+ path.text == 'different'
+
+ when:
+ verifyingSnapshotter.assertThat("reference").matchesSnapshot(snapshotId)
+
+ then:
+ !Files.exists(path)
+
+ cleanup:
+ capturer.stop()
+
+ where:
+ snapshotId << ['', 'my-id']
+ }
}