Skip to main content

Storing snapshots elsewhere

By default the snapshots are files next to the tests. To keep them elsewhere, e.g. in a shared folder or a database, derive from SnapshotStore in the namespace aweXpect.Snapshots:

public sealed class FolderSnapshotStore(string root) : SnapshotStore
{
public override ValueTask<byte[]?> ReadAsync(SnapshotId id, CancellationToken cancellationToken)
{
string path = GetPath(id, "snapshot");
return new ValueTask<byte[]?>(File.Exists(path) ? File.ReadAllBytes(path) : null);
}

public override ValueTask RecordMismatchAsync(SnapshotId id, byte[] actual, CancellationToken cancellationToken)
{
string path = GetPath(id, "pending");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, actual);
return default;
}

public override ValueTask AcceptAsync(SnapshotId id, byte[] actual, CancellationToken cancellationToken)
{
string path = GetPath(id, "snapshot");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, actual);
File.Delete(GetPath(id, "pending"));
return default;
}

public override string Describe(SnapshotId id) => GetPath(id, "snapshot");

public override string? DescribeMismatch(SnapshotId id) => GetPath(id, "pending");

private string GetPath(SnapshotId id, string kind)
=> Path.Combine(root, Path.GetDirectoryName(id.SourceFile) ?? "", $"{id}.{kind}.{id.Extension}");
}

A snapshot is passed as bytes, which the store keeps unchanged. The library encodes the text of a string or an object as UTF-8 without a byte order mark, with an additional final line break that it removes again when reading, and passes binary data as it is, so one store keeps both.

The operations describe what happened in the expectation, so the store decides how it keeps a value that awaits review apart from the reviewed snapshot:

MemberCalledIf it throws
ReadAsyncto read the reviewed snapshot, null if nonethe expectation fails with the exception
RecordMismatchAsyncwhen the snapshot is missing or differentthe failure names the error
AcceptAsyncinstead, while accepting changesthe failure names the error, and the mismatch is recorded instead
RecordMatchAsyncwhen the snapshot matches, e.g. to clean upthe error is ignored, as the snapshot matched
Describeto name the snapshot in the failure
DescribeMismatchto name the recorded mismatch, null to omit
ListAsyncto list the snapshots of the test project for the check, null if it cannotthe check fails with the exception
DeleteAsyncto delete an orphaned snapshot and its mismatch, while accepting changesthe check names the error and continues

RecordMatchAsync, Describe, DescribeMismatch, ListAsync and DeleteAsync are optional. A store that does not list its snapshots fails OrphanedSnapshots.CheckAsync(), so that a check that was asked for never passes without checking anything. A cancellation of the evaluation still aborts it.

A SnapshotId consists of the path of the test file relative to the repository (SourceFile, e.g. Tests/AlbumTests.cs, see Deterministic builds), the name of the test method (MemberName), the name from .Named(…) (Name) and the environment from .UniqueFor (Variant, e.g. net8.0@windows, see Snapshots per environment). It is a record, so it can be used as a key, and its ToString() returns AlbumTests.Describes, AlbumTests.Describes.Abbey Road or AlbumTests.Describes.Abbey Road@net8.0@windows. A listed snapshot whose Variant is for another environment is skipped by the check. Extension is the file extension from .WithExtension(…) or of a FileInfo, txt by default, e.g. to choose a content type; it is not part of ToString().

Set the store for all tests, e.g. in an assembly-level setup, or only for the current async flow:

Customize.aweXpect.Global.Snapshots().Store.Set(new FolderSnapshotStore("/shared/snapshots"));

using (Customize.aweXpect.Snapshots().Store.Set(new FolderSnapshotStore("/shared/snapshots")))
{
await Expect.That(description).MatchesSnapshot();
}

The store is taken when MatchesSnapshot() is called.