Skip to content

Commit

Permalink
Add support for .actual files
Browse files Browse the repository at this point in the history
  • Loading branch information
leonard84 committed Feb 16, 2024
1 parent 174b4b4 commit b613d5d
Show file tree
Hide file tree
Showing 5 changed files with 116 additions and 6 deletions.
7 changes: 7 additions & 0 deletions docs/extensions.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
----

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* The file will be deleted upon a successful match.
*/
public boolean writeActualSnapshotOnMismatch = Boolean.getBoolean("spock.snapshots.writeActual");
/**
* The default extension to use.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
48 changes: 47 additions & 1 deletion spock-core/src/main/java/spock/lang/Snapshotter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <P>
Expand Down Expand Up @@ -171,10 +182,16 @@ public void matchesSnapshot(String snapshotId, BiConsumer<String, String> 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;
}
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,28 @@

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
Path tmpDir

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)
Expand Down Expand Up @@ -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']
}
}

0 comments on commit b613d5d

Please sign in to comment.