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'] + } }