Snapshot recipes
Install XBullet.EasyTesting.Snapshots.Core for values, JSON, text, controller responses, and real
TestServer exchanges. Add XBullet.EasyTesting.Snapshots.Http only for outbound requests captured
by StubHttpMessageHandler.
Values and raw JSON
await SnapshotAssert.MatchAsync(result, settings, cancellationToken);
await SnapshotAssert.MatchJsonAsync(rawJson, settings, cancellationToken);
MatchAsync serializes with System.Text.Json. MatchJsonAsync parses and normalizes a raw JSON
string instead of storing it as an escaped string. For HTTP content or response bodies:
await response.Content.ShouldMatchJsonSnapshot(settings, cancellationToken);
await response.ShouldMatchJsonBodySnapshot(settings, cancellationToken);
Use body-only assertions when request metadata, status, and headers are outside the contract.
Plain text
MatchTextAsync preserves text rather than parsing JSON and creates .txt files:
[Fact]
public async Task Plain_text_snapshots_use_text_files_and_custom_scrubbers()
{
var cancellationToken = TestContext.Current.CancellationToken;
var snapshotDirectory = CreateTemporarySnapshotDirectory();
var settings = CreateUpdatingSettings(snapshotDirectory)
.Named("plain-text")
.Scrub(value => value.Replace("secret", "{Redacted}", StringComparison.Ordinal));
try
{
await SnapshotAssert.MatchTextAsync(
"first\r\nsecret\r\n",
settings,
cancellationToken);
var verifiedPath = Directory.EnumerateFiles(snapshotDirectory, "*.verified.txt").Single();
Assert.Equal("first\n{Redacted}", await File.ReadAllTextAsync(verifiedPath, cancellationToken));
settings.Updating(SnapshotUpdateMode.None);
var exception = await Assert.ThrowsAsync<SnapshotMismatchException>(() =>
SnapshotAssert.MatchTextAsync("second\nsecret", settings, cancellationToken));
Assert.Matches(@"\.received\.net(8|9|10)\.0\.txt$", exception.ReceivedPath);
Assert.Equal("$text", exception.DifferencePath);
Assert.Equal(verifiedPath, SnapshotAssert.AcceptReceived(exception.ReceivedPath));
await SnapshotAssert.MatchTextAsync("second\nsecret", settings, cancellationToken);
}
finally
{
DeleteTemporarySnapshotDirectory(snapshotDirectory);
}
}
String scrubbers apply to text snapshots. Prefer structural rules for JSON so formatting changes do not hide or create false differences.
Controller responses
ShouldMatchControllerSnapshot captures a real response with optional request, status, headers,
and body controls:
[Fact]
public async Task Controller_response_extension_forwards_capture_and_snapshot_options()
{
var cancellationToken = TestContext.Current.CancellationToken;
var snapshotDirectory = CreateTemporarySnapshotDirectory();
var sourceFile = Path.Combine(snapshotDirectory, "ControllerExtensionTests.cs");
var settings = CreateUpdatingSettings(snapshotDirectory).Named("controller-extension");
using var response = new HttpResponseMessage(HttpStatusCode.Accepted)
{
RequestMessage = new HttpRequestMessage(HttpMethod.Post, "https://example.test/orders"),
Content = new StringContent("ignored", Encoding.UTF8, "text/plain")
};
try
{
await response.ShouldMatchControllerSnapshot(
new ControllerSnapshotOptions().WithoutBody(),
settings,
cancellationToken,
sourceFile,
"Controller_extension");
var verifiedPath = Directory.EnumerateFiles(snapshotDirectory, "*.verified.json").Single();
var verified = await File.ReadAllTextAsync(verifiedPath, cancellationToken);
Assert.Equal("ControllerExtensionTests.controller-extension.verified.json", Path.GetFileName(verifiedPath));
Assert.Contains("\"StatusCode\": 202", verified);
Assert.Contains("\"Method\": \"POST\"", verified);
Assert.DoesNotContain("ignored", verified);
}
finally
{
DeleteTemporarySnapshotDirectory(snapshotDirectory);
}
}
Use ControllerSnapshotOptions to omit the request or body, ignore unstable headers, redact
sensitive header and query values, and scrub volatile URL segments.
Complete TestServer exchanges
ShouldMatchHttpExchangeSnapshot supports both direct capture from a response and exchanges
already captured by an HttpExchangeRecorder. Recorded responses also support per-assertion options.
Choose where to configure exchange options
| Response source | When capture happens | Where to configure exchange options |
|---|---|---|
Client without HttpExchangeRecorder |
During the assertion, from response and response.RequestMessage |
configureExchange or exchangeOptions on ShouldMatchHttpExchangeSnapshot |
Client with HttpExchangeRecorder, including SnapshotScenario(...) |
Request before transport; response when received and body when consumed | Recorder or scenario creation for capture; configureExchange or exchangeOptions on the assertion to transform captured data |
For direct capture, the assertion can configure headers, bodies, redaction, URL scrubbing, and output format:
using var response = await client.GetAsync($"/api/action/{id}", cancellationToken);
await response.ShouldMatchHttpExchangeSnapshot(
configureExchange: options => options.Request.ScrubbingUrlPathGuids(),
cancellationToken: cancellationToken);
Without an HttpExchangeRecorder, direct capture reads the data still available during assertion;
it cannot recover request content already consumed by transport or record a send failure that
produced no response.
For recorded responses, configureExchange receives an independent copy of the recorder's effective
options. Separate exchangeOptions instances override recorder settings. Both transform a copy of
the captured exchange and can select JSON, HTTP text, or YAML output without changing the recorder
or reading request or response content again. Capture-time path scrubbers are not replayed.
Assertion options can omit headers or bodies and add redaction, query scrubbing, and path scrubbing. They cannot restore data excluded or redacted during capture. Capture-time query redaction remains authoritative over ordinary scrubbing. Configure required capture data before sending the request. Passing the original recorder options instance retains the recorded exchange unchanged.
configureSnapshot and snapshotSettings work for both response sources. They transform the
snapshot after capture and control naming, storage, comparison, and updates.
Configure recorder-backed exchange capture
Attach an HttpExchangeRecorder as a delegating handler when the request body must be captured
before transport consumes it. The recorder observes the real TestServer pipeline. The example
factory's SnapshotClient(...) helper attaches this handler:
[Fact]
public Task Create_matches_custom_snapshot_with_generated_id_scrubbed() =>
Run(async (scope, cancellationToken) =>
{
using var client = _factory.SnapshotClient(
scope,
options => options.Response.IgnoringHeaders("Location"))
.AsUser(user => user.WithName("CRUD snapshot tester"))
.Build();
using var response = await client.PostAsJsonAsync(
"/api/products",
new ProductRequest("Webcam", 79.95m),
cancellationToken);
await response.ShouldMatchHttpExchangeSnapshot(
snapshotSettings: BuiltInSnapshotAudit.CreateSettings(
settings => settings.ScrubMember("id")),
cancellationToken: cancellationToken);
});
The XBullet.EasyTesting.Snapshots.Http package can also attach the recorder directly to a fluent
scenario. This is required when the request body belongs in the exchange snapshot because the
server may consume it before the response is asserted:
using var result = await scope.SnapshotScenario(options =>
{
options.Request.ScrubbingUrlPathGuids();
options.Response.IgnoringHeaders("Location");
})
.PostJson("/api/products", new ProductRequest("Webcam", 79.95m))
.ExecuteAsync(cancellationToken);
await result.Response.ShouldMatchHttpExchangeSnapshot(
configureExchange: options => options.Request.ScrubbingQueryParameter("nonce"),
configureSnapshot: settings => settings.ScrubMember("id"),
cancellationToken: cancellationToken);
For GUID URL scrubbing during assertion, use SnapshotSettings with either response source:
await response.ShouldMatchHttpExchangeSnapshot(
configureSnapshot: settings => settings.ScrubbingUrlPathGuids(),
cancellationToken: cancellationToken);
See HTTP redaction and URL stability for selective GUID scrubbing. This settings rule leaves the recorded exchange and its capture options unchanged.
JSON is the default. Set HttpExchangeSnapshotOptions.Format to Http for a transcript or Yaml
for deterministic YAML, at the capture configuration point shown above. With a recorder, an unread
streaming response body is represented as {NotRead}; content-read errors are recorded as
BodyFailure, and send failures are captured separately.
Outbound HTTP stub requests and exchanges
Install the adapter package:
dotnet add package XBullet.EasyTesting.Snapshots.Http
Snapshot one recorded request, all requests, or complete request/response exchanges:
[Fact]
public async Task Captured_http_requests_have_a_dedicated_snapshot_assertion()
{
var cancellationToken = TestContext.Current.CancellationToken;
var snapshotDirectory = CreateTemporarySnapshotDirectory();
using var handler = new StubHttpMessageHandler();
handler
.When(HttpMethod.Post, "/orders?notify=true")
.RespondJson(new { Accepted = true }, HttpStatusCode.Created);
using var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://external.example.test/")
};
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "secret-token");
client.DefaultRequestHeaders.Add("X-Tenant", "tenant-42");
try
{
using var response = await client.PostAsJsonAsync(
"/orders?notify=true",
new { OrderId = 42 },
cancellationToken);
await handler.ShouldMatchRequestsSnapshot(
configureSnapshot: settings => settings
.InDirectory(snapshotDirectory)
.Named("outbound-requests")
.Updating(SnapshotUpdateMode.Missing)
.AllowingUpdatesInContinuousIntegration()
.WithoutDiffTool(),
cancellationToken: cancellationToken);
await handler.ShouldMatchExchangesSnapshot(
configureSnapshot: settings => settings
.InDirectory(snapshotDirectory)
.Named("outbound-exchanges")
.Updating(SnapshotUpdateMode.Missing)
.AllowingUpdatesInContinuousIntegration()
.WithoutDiffTool(),
cancellationToken: cancellationToken);
var verifiedPaths = Directory
.EnumerateFiles(snapshotDirectory, "*.verified.json")
.ToArray();
var requestsVerified = await File.ReadAllTextAsync(
verifiedPaths.Single(path => path.Contains("outbound-requests")),
cancellationToken);
var exchangesVerified = await File.ReadAllTextAsync(
verifiedPaths.Single(path => path.Contains("outbound-exchanges")),
cancellationToken);
Assert.Contains("\"Method\": \"POST\"", requestsVerified);
Assert.Contains("\"Url\": \"/orders?notify=true\"", requestsVerified);
Assert.Contains("\"orderId\": 42", requestsVerified);
Assert.Contains("\"X-Tenant\"", requestsVerified);
Assert.DoesNotContain("Authorization", requestsVerified);
Assert.DoesNotContain("secret-token", requestsVerified);
Assert.Contains("\"Response\"", exchangesVerified);
Assert.Contains("\"StatusCode\": 201", exchangesVerified);
Assert.Contains("\"accepted\": true", exchangesVerified);
Assert.DoesNotContain("\"Failure\"", exchangesVerified);
Assert.DoesNotContain("\"BodyFailure\"", exchangesVerified);
}
finally
{
DeleteTemporarySnapshotDirectory(snapshotDirectory);
}
}
Authentication, cookies, common API-key headers, correlation IDs, and tracing headers are excluded by default. Configure request and response capture independently. Outbound exchange snapshots can also use JSON, HTTP transcript, or YAML formats.
Sensitive complete exchange
This realistic test redacts a request header, scrubs nested personal data, and replaces a dynamic route before writing the exchange:
[Fact]
public Task Update_matches_full_exchange_with_nested_request_and_path_scrubbing() =>
Run(async (scope, cancellationToken) =>
{
await _factory.Database(scope)
.Seed(new Product { Id = 922, Name = "Before", Price = 10m })
.ExecuteAsync(cancellationToken);
using var client = _factory.SnapshotClient(
scope,
options => options.Request.RedactingHeader("X-Request-Secret"))
.AsUser(user => user.WithName("CRUD snapshot tester"))
.WithHeader("X-Request-Secret", "request-secret-value")
.Build();
using var response = await client.PutAsJsonAsync(
"/api/products/922",
new
{
Name = "Updated with nested metadata",
Price = 27.50m,
Customer = new
{
Id = 701,
Contact = new
{
Email = "private@example.test",
Locale = "en-US"
}
}
},
cancellationToken);
var settings = BuiltInSnapshotAudit.CreateSettings(settings => settings
.ScrubMember("email")
.ScrubPath("/Request/Body/customer/id")
.ReplacePath("/Request/Url", "/api/products/{id}"));
await response.ShouldMatchHttpExchangeSnapshot(
snapshotSettings: settings,
cancellationToken: cancellationToken);
});
Next: stabilize dynamic and sensitive data and configure shared defaults.
API references: snapshot core and outbound HTTP adapters.