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

  • ScrubMember or ScrubMembers keeps the property and writes {Scrubbed}.
  • IgnoreMember or IgnoreMembers removes the property.
  • ScrubGuids replaces JSON string GUIDs with {Guid}.
  • ScrubDateTimes replaces round-trip date/time strings with {DateTime}.
  • Scrub applies 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.