Stabilize dynamic and sensitive snapshot data
Transform only values that are nondeterministic, sensitive, or irrelevant to the contract. A scrubber that hides meaningful behavior makes a snapshot pass for the wrong reason.
Member rules
ScrubMemberorScrubMemberskeeps the property and writes{Scrubbed}.IgnoreMemberorIgnoreMembersremoves the property.ScrubGuidsreplaces JSON string GUIDs with{Guid}.ScrubDateTimesreplaces round-trip date/time strings with{DateTime}.Scrubapplies a final custom string transformation.
Member matching is case-insensitive and recursive. This executable before/after test proves that different IDs and timestamps match while a secret property never reaches the verified file:
[Fact]
public async Task Structured_scrubbing_stabilizes_dynamic_values_and_ignored_members()
{
var cancellationToken = TestContext.Current.CancellationToken;
var snapshotDirectory = CreateTemporarySnapshotDirectory();
var settings = new SnapshotSettings()
.InDirectory(snapshotDirectory)
.Named("structured-scrubbing")
.ScrubMembers("RequestId", "CreatedAt")
.IgnoreMembers("AccessToken")
.ScrubGuids()
.ScrubDateTimes()
.Updating(SnapshotUpdateMode.Missing)
.AllowingUpdatesInContinuousIntegration()
.WithoutDiffTool();
try
{
await SnapshotAssert.MatchAsync(
new
{
RequestId = 123,
CreatedAt = new DateTimeOffset(2026, 1, 1, 10, 0, 0, TimeSpan.Zero),
AccessToken = "secret-one",
Metadata = new
{
CorrelationId = Guid.Parse("3c91271a-a927-4516-b225-41cce45963b5"),
ExpiresAt = "2026-01-01T11:00:00Z"
}
},
settings,
cancellationToken);
settings.Updating(SnapshotUpdateMode.None);
await SnapshotAssert.MatchAsync(
new
{
RequestId = 999,
CreatedAt = new DateTimeOffset(2030, 2, 2, 12, 0, 0, TimeSpan.Zero),
AccessToken = "secret-two",
Metadata = new
{
CorrelationId = Guid.Parse("637717e4-10d3-4302-bff2-846b17f00591"),
ExpiresAt = "2030-02-02T13:00:00Z"
}
},
settings,
cancellationToken);
var verifiedPath = Directory.EnumerateFiles(snapshotDirectory, "*.verified.json").Single();
var verified = await File.ReadAllTextAsync(verifiedPath, cancellationToken);
Assert.Contains("\"RequestId\": \"{Scrubbed}\"", verified);
Assert.Contains("\"CorrelationId\": \"{Guid}\"", verified);
Assert.Contains("\"ExpiresAt\": \"{DateTime}\"", verified);
Assert.DoesNotContain("AccessToken", verified);
Assert.DoesNotContain("secret-one", verified);
}
finally
{
DeleteTemporarySnapshotDirectory(snapshotDirectory);
}
}
Conceptually, this input:
{"RequestId":123,"AccessToken":"secret","Metadata":{"CorrelationId":"3c91271a-a927-4516-b225-41cce45963b5"}}
becomes:
{"RequestId":"{Scrubbed}","Metadata":{"CorrelationId":"{Guid}"}}
Windows and Linux line endings
Snapshot files already normalize physical CRLF and CR line endings to LF. Newlines inside JSON string values are escaped, so they remain significant by default. For messages generated with platform-specific newlines, opt in to normalization:
var settings = new SnapshotSettings().NormalizeStringLineEndings();
await SnapshotAssert.MatchAsync(result, settings, cancellationToken);
For controller or complete exchange snapshots rendered as JSON:
await response.ShouldMatchHttpExchangeSnapshot(
configureSnapshot: settings => settings.NormalizeStringLineEndings(),
cancellationToken: cancellationToken);
To enable the option throughout a test assembly, configure it once during initialization alongside any other global settings:
SnapshotSettingsDefaults.Global = new(settings => settings.NormalizeStringLineEndings());
The rule converts decoded CRLF and CR to LF in string values at every nesting level, including arrays and a root string. It runs after scrubbing on actual JSON and on existing verified JSON in memory. Existing Windows snapshots can therefore match Linux results without updating reviewed files. New verified files and received files contain normalized string values.
Trailing newlines inside values are preserved. Property names, numbers, whitespace, and literal
backslash sequences such as \\r\\n are preserved. HTTP text and YAML output retain their existing
behavior; this setting applies only to JSON snapshots produced by the built-in engine. It does not
configure Verify.Xunit. Hashing and sorting rules run before this final normalization, so newline
differences already represented by hashes or array order remain significant.
If an exchange includes Content-Length, ignore that header separately when the platform-specific
body length is irrelevant to the test.
Path-specific rules
Use extended JSON Pointer rules when a member name elsewhere must remain visible:
[Fact]
public async Task Json_pointer_rules_target_nested_and_wildcard_values()
{
var cancellationToken = TestContext.Current.CancellationToken;
var snapshotDirectory = CreateTemporarySnapshotDirectory();
var settings = CreateUpdatingSettings(snapshotDirectory)
.Named("pointer-rules")
.ScrubPath("/orders/*/id")
.IgnorePath("/orders/*/generatedAt")
.ReplacePath("/status", "stable")
.ReplacePath("/escaped~1name/~2", "literal-star");
try
{
await SnapshotAssert.MatchJsonAsync(
"""
{
"status": "created",
"orders": [
{ "id": 101, "name": "Keyboard", "generatedAt": "2026-01-01T10:00:00Z" },
{ "id": 102, "name": "Mouse", "generatedAt": "2026-01-01T10:01:00Z" }
],
"escaped/name": { "*": "dynamic" }
}
""",
settings,
cancellationToken);
settings.Updating(SnapshotUpdateMode.None);
await SnapshotAssert.MatchJsonAsync(
"""
{
"status": "completed",
"orders": [
{ "id": 901, "name": "Keyboard", "generatedAt": "2030-02-02T12:00:00Z" },
{ "id": 902, "name": "Mouse", "generatedAt": "2030-02-02T12:01:00Z" }
],
"escaped/name": { "*": "changed" }
}
""",
settings,
cancellationToken);
var verified = await ReadSingleVerifiedSnapshotAsync(snapshotDirectory, cancellationToken);
Assert.Equal(2, CountOccurrences(verified, "{Scrubbed}"));
Assert.Contains("\"status\": \"stable\"", verified);
Assert.Contains("\"*\": \"literal-star\"", verified);
Assert.DoesNotContain("generatedAt", verified);
}
finally
{
DeleteTemporarySnapshotDirectory(snapshotDirectory);
}
}
Available rules include ScrubPath, IgnorePath, ReplacePath, HashPath, and SortArray.
Paths are case-sensitive. * selects one object or array level; escape / as ~1, ~ as ~0,
and a literal wildcard property as ~2.
Hash large or sensitive subtrees
HashPath replaces a selected value with a SHA-256 marker computed from canonical JSON. Equivalent
objects hash identically even when property order differs:
[Fact]
public async Task Hashed_paths_use_canonical_json_content()
{
var cancellationToken = TestContext.Current.CancellationToken;
var snapshotDirectory = CreateTemporarySnapshotDirectory();
var settings = CreateUpdatingSettings(snapshotDirectory)
.Named("hashed-path")
.HashPath("/payload");
try
{
await SnapshotAssert.MatchJsonAsync(
"""{"name":"document","payload":{"b":2,"a":1}}""",
settings,
cancellationToken);
settings.Updating(SnapshotUpdateMode.None);
await SnapshotAssert.MatchJsonAsync(
"""{"name":"document","payload":{"a":1,"b":2}}""",
settings,
cancellationToken);
var verified = await ReadSingleVerifiedSnapshotAsync(snapshotDirectory, cancellationToken);
Assert.Contains("\"payload\": \"sha256:", verified);
Assert.DoesNotContain("\"a\": 1", verified);
}
finally
{
DeleteTemporarySnapshotDirectory(snapshotDirectory);
}
}
Hashing hides content but still reveals whether it changed. Do not treat an unsalted hash of a small secret domain as cryptographic protection; omit or redact credentials instead.
Canonical properties and sorted arrays
Use CanonicalizeJson when object property order is incidental. Use SortArray(path, keyPath) only
when array order is not part of behavior:
[Fact]
public async Task Canonical_properties_and_sorted_arrays_ignore_incidental_order()
{
var cancellationToken = TestContext.Current.CancellationToken;
var snapshotDirectory = CreateTemporarySnapshotDirectory();
var settings = CreateUpdatingSettings(snapshotDirectory)
.Named("canonical-order")
.SortArray("/items", "/id")
.CanonicalizeJson();
try
{
await SnapshotAssert.MatchJsonAsync(
"""{"z":1,"items":[{"name":"B","id":"2"},{"name":"A","id":"1"}],"a":2}""",
settings,
cancellationToken);
settings.Updating(SnapshotUpdateMode.None);
await SnapshotAssert.MatchJsonAsync(
"""{"a":2,"items":[{"id":"1","name":"A"},{"id":"2","name":"B"}],"z":1}""",
settings,
cancellationToken);
var verified = await ReadSingleVerifiedSnapshotAsync(snapshotDirectory, cancellationToken);
Assert.True(
verified.IndexOf("\"a\"", StringComparison.Ordinal) <
verified.IndexOf("\"items\"", StringComparison.Ordinal));
Assert.True(
verified.IndexOf("\"id\": \"1\"", StringComparison.Ordinal) <
verified.IndexOf("\"id\": \"2\"", StringComparison.Ordinal));
}
finally
{
DeleteTemporarySnapshotDirectory(snapshotDirectory);
}
}
Do not sort event streams, priority lists, or API results whose order is contractual.
HTTP redaction and URL stability
Controller, TestServer exchange, and outbound-stub options support:
- Ignoring a header or query parameter entirely.
- Redacting its value as
{Redacted}while retaining its presence. - Explicitly including a normally excluded value when it is safe and stable.
- Scrubbing GUID route segments as
{Guid}and selected query values as{Scrubbed}. - Replacing custom route values with
ScrubbingUrlPath.
Common secret query names—including access_token, api_key, client_secret, sas, secret,
sig, and token—are redacted by default. If a name is both scrubbed and redacted, security
redaction wins. Structured member and path scrubbers likewise preserve values already represented
by {Redacted}, including redacted header arrays. Application-specific sensitive names still
require explicit configuration.
To scrub GUID route segments during assertion, use SnapshotSettings.ScrubbingUrlPathGuids():
await response.ShouldMatchHttpExchangeSnapshot(
configureSnapshot: settings => settings.ScrubbingUrlPathGuids(),
cancellationToken: cancellationToken);
This transforms JSON string members named Url, matched case-insensitively at every nesting level,
so /api/action/3c91271a-a927-4516-b225-41cce45963b5 becomes /api/action/{Guid}. It works with
recorder-backed responses, controller snapshots, and outbound request snapshots, including
collections. Exchange snapshots apply the rule before rendering JSON, HTTP text, or YAML.
Hosts, query strings, fragments, other members, and plain-text snapshots are preserved.
The rule also works in prebuilt settings and reusable defaults.
Pass a predicate to preserve selected GUID segments:
await response.ShouldMatchHttpExchangeSnapshot(
configureSnapshot: settings => settings
.ScrubbingUrlPathGuids((position, segment) => position != 0),
cancellationToken: cancellationToken);
For /<guid1>/ss/<guid2>, this preserves <guid1> and replaces <guid2> with {Guid}.
Use position != 2 to preserve the middle GUID in /<guid1>/ss/<guid2>/end/<guid3>.
Positions are zero-based and count every path segment, including ordinary text and internal
empty segments. The initial slash and URL authority are excluded. The predicate runs only for
complete GUID segments and receives their original text; positions restart for each URL.
Keep it deterministic without shared mutable counters. To preserve a known GUID anywhere,
use (position, segment) => !Guid.Parse(segment).Equals(knownId).
The most recent ScrubbingUrlPathGuids call selects the rule. Calling it without a predicate
scrubs every GUID segment again. An explicit local rule replaces a reusable or global defaults
rule without mutating the template.
Use ScrubGuids() separately to scrub JSON values that consist entirely of a GUID.
For recorder-backed responses, configure required capture data on the recorder or
SnapshotScenario(...). Assertion-time configureExchange callbacks can omit or further scrub
captured data and select output format without mutating the recorder. Excluded or redacted
capture data cannot be restored.
This settings rule transforms the captured snapshot without changing capture options or the
recorded exchange. See where to configure exchange options
for the direct-capture and recorder workflows.
Review the complete-exchange recipe for a realistic nested body and request-header example.
See the snapshot core API reference.