About snapshot-testing
This skill explains snapshot testing with Verify for .NET test projects. It guides developers through capturing outputs, reviewing approved baselines, and handling changing data across objects, HTTP responses, rendered emails, and API surfaces.
Key features
- 01Capture and compare approved output snapshots
- 02Scrub dynamic values and ignore volatile members
- 03Verify objects, HTTP responses, and rendered emails
- 04Approve public API changes with snapshots
- 05Organize snapshots and parameterized test files
- 06Accept reviewed changes with the Verify CLI
Use cases
- 01Detect unintended changes in serialized output
- 02Review HTML email or report rendering changes
- 03Track public API changes for breaking differences
- 04Compare HTTP response bodies and headers
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill snapshot-testingAfter installing, type/snapshot-testingin Claude Code to confirm it loaded.
Details
From the project READMESnapshot Testing with Verify
When to Use This Skill
Use snapshot testing when:
- Verifying rendered output (HTML emails, reports, generated code)
- Approving public API surfaces for breaking change detection
- Testing HTTP response bodies and headers
- Validating serialization output
- Catching unintended changes in complex objects
What is Snapshot Testing?
Snapshot testing captures output and compares it against a human-approved baseline:
- First run: Test generates a
.received.file with actual output - Human review: Developer approves it, creating a
.verified.file - Subsequent runs: Test compares output against
.verified.file - Changes detected: Test fails, diff tool shows differences for review
This catches unintended changes while allowing intentional changes through explicit approval.
Setup
Packages
<PackageReference Include="Verify.Xunit" Version="20.*" />
<PackageReference Include="Verify.Http" Version="6.*" />
Module Initializer
Verify requires a one-time initialization per test assembly:
// ModuleInitializer.cs
using System.Runtime.CompilerServices;
public static class ModuleInitializer
{
[ModuleInitializer]
public static void Init()
{
// Use source-file-relative paths for verified files
VerifyBase.UseProjectRelativeDirectory("Snapshots");
// Scrub common non-deterministic types globally
VerifierSettings.ScrubMembersWithType<DateTime>();
VerifierSettings.ScrubMembersWithType<DateTimeOffset>();
VerifierSettings.ScrubMembersWithType<Guid>();
// In CI, fail instead of launching diff tool
if (Environment.GetEnvironmentVariable("CI") is not null)
{
DiffRunner.Disabled = true;
}
}
}
Source Control
Add to .gitignore:
# Verify received files (test failures)
*.received.*
Add to .gitattributes:
*.verified.txt text eol=lf
*.verified.xml text eol=lf
*.verified.json text eol=lf
*.verified.html text eol=lf
Basic Usage
Verifying Objects
[UsesVerify]
public class OrderSerializationTests
{
[Fact]
public Task Serialize_CompletedOrder_MatchesSnapshot()
{
var order = new Order
{
Id = 1,
CustomerId = "cust-123",
Status = OrderStatus.Completed,
Items =
[
new OrderItem("SKU-001", Quantity: 2, UnitPrice: 29.99m),
new OrderItem("SKU-002", Quantity: 1, UnitPrice: 49.99m)
],
Total = 109.97m
};
return Verify(order);
}
}
Creates OrderSerializationTests.Serialize_CompletedOrder_MatchesSnapshot.verified.txt.
Verifying Strings and Streams
[Fact]
public Task RenderInvoice_MatchesExpectedHtml()
{
var html = invoiceRenderer.Render(order);
return Verify(html, extension: "html");
}
[Fact]
public Task ExportReport_MatchesExpectedXml()
{
var stream = reportExporter.Export(report);
return Verify(stream, extension: "xml");
}
Scrubbing and Filtering
Non-deterministic values (dates, GUIDs, auto-incremented IDs) change between test runs. Scrubbing replaces them with stable placeholders.
Built-In Scrubbers
[Fact]
public Task CreateOrder_ScrubsNonDeterministicValues()
{
var order = new Order
{
Id = Guid.NewGuid(), // Scrubbed to Guid_1
CreatedAt = DateTime.UtcNow, // Scrubbed to DateTime_1
TrackingNumber = Guid.NewGuid().ToString() // Scrubbed to Guid_2
};
return Verify(order);
}
Produces stable output:
{
Id: Guid_1,
CreatedAt: DateTime_1,
TrackingNumber: Guid_2
}
Custom Scrubbers
[Fact]
public Task AuditLog_ScrubsTimestampsAndMachineNames()
{
var log = auditService.GetRecentEntries();
return Verify(log)
.ScrubLinesWithReplace(line =>
Regex.Replace(line, @"Machine:\s+\w+", "Machine: Scrubbed"))
.ScrubLinesContaining("CorrelationId:");
}
Ignoring Members
[Fact]
public Task OrderSnapshot_IgnoresVolatileFields()
{
var order = orderService.CreateOrder(request);
return Verify(order)
.IgnoreMember("CreatedAt")
.IgnoreMember("UpdatedAt")
.IgnoreMember("ETag");
}
Scrubbing Inline Values
[Fact]
public Task ApiResponse_ScrubsTokens()
{
var response = authService.GenerateTokenResponse(user);
return Verify(response)
.ScrubLinesWithReplace(line =>
Regex.Replace(line, @"Bearer [A-Za-z0-9\-._~+/]+=*", "Bearer {scrubbed}"));
}
Verifying HTTP Responses
Full HTTP Responses
[UsesVerify]
public class OrdersApiSnapshotTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersApiSnapshotTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetOrders_ResponseMatchesSnapshot()
{
var response = await _client.GetAsync("/api/orders");
await Verify(response);
}
}
Specific Response Parts
[Fact]
public async Task CreateOrder_VerifyResponseBody()
{
var response = await _client.PostAsJsonAsync("/api/orders", request);
var body = await response.Content.ReadFromJsonAsync<OrderDto>();
await Verify(body)
.IgnoreMember("Id")
.IgnoreMember("CreatedAt");
}
Verifying Rendered Emails
Snapshot-test email templates by verifying the rendered HTML output:
[UsesVerify]
public class EmailTemplateTests
{
private readonly EmailRenderer _renderer = new();
[Fact]
public Task OrderConfirmation_MatchesSnapshot()
{
var model = new OrderConfirmationModel
{
CustomerName = "Alice Johnson",
OrderNumber = "ORD-001",
Items =
[
new("Widget A", Quantity: 2, Price: 29.99m),
new("Widget B", Quantity: 1, Price: 49.99m)
],
Total = 109.97m
};
var html = _renderer.RenderOrderConfirmation(model);
return Verify(html, extension: "html");
}
[Fact]
public Task PasswordReset_MatchesSnapshot()
{
var model = new PasswordResetModel
{
UserName = "alice",
ResetLink = "https://example.com/reset?token=test-token"
};
var html = _renderer.RenderPasswordReset(model);
return Verify(html, extension: "html")
.ScrubLinesWithReplace(line =>
Regex.Replace(line, @"token=[^""&]+", "token={scrubbed}"));
}
}
Benefits for email testing:
- Catches CSS/layout regressions
- Detects broken template variables
- Visual review in diff tool
- Version control tracks email changes
API Surface Approval
Prevent accidental breaking changes to public APIs:
[Fact]
public Task ApprovePublicApi()
{
var assembly = typeof(MyLibrary.PublicClass).Assembly;
var publicApi = assembly.GetExportedTypes()
.OrderBy(t => t.FullName)
.Select(t => new
{
Type = t.FullName,
Members = t.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
.Where(m => m.DeclaringType == t)
.OrderBy(m => m.Name)
.Select(m => m.ToString())
});
return Verify(publicApi);
}
Or use the dedicated ApiApprover package:
<PackageReference Include="PublicApiGenerator" />
<PackageReference Include="Verify.Xunit" />
[Fact]
public Task ApproveApi()
{
var api = typeof(MyPublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}
Creates .verified.txt with full API surface - any change requires explicit approval.
Custom Converters
Control how specific types are serialized for verification:
public class MoneyConverter : WriteOnlyJsonConverter<Money>
{
public override void Write(VerifyJsonWriter writer, Money value)
{
writer.WriteStartObject();
writer.WriteMember(value, value.Amount, "Amount");
writer.WriteMember(value, value.Currency.Code, "Currency");
writer.WriteEndObject();
}
}
public class AddressConverter : WriteOnlyJsonConverter<Address>
{
public override void Write(VerifyJsonWriter writer, Address value)
{
// Single-line summary for compact snapshots
writer.WriteValue($"{value.Street}, {value.City}, {value.State} {value.Zip}");
}
}
Register in the module initializer:
[ModuleInitializer]
public static void Init()
{
VerifierSettings.AddExtraSettings(settings =>
{
settings.Converters.Add(new MoneyConverter());
settings.Converters.Add(new AddressConverter());
});
}
Snapshot File Organization
Unique Directory
Move verified files into a dedicated directory:
[ModuleInitializer]
public static void Init()
{
Verifier.DerivePathInfo(
(sourceFile, projectDirectory, type, method) =>
new PathInfo(
directory: Path.Combine(projectDirectory, "Snapshots"),
typeName: type.Name,
methodName: method.Name));
}
Parameterized Tests
For [Theory] tests, use UseParameters():
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("ja-JP")]
public Task FormatCurrency_ByLocale_MatchesSnapshot(string locale)
{
var formatted = currencyFormatter.Format(1234.56m, locale);
return Verify(formatted).UseParameters(locale);
}
Creates separate files:
FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=en-US.verified.txt
FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=de-DE.verified.txt
FormatCurrencyTests.FormatCurrency_ByLocale_MatchesSnapshot_locale=ja-JP.verified.txt
Workflow: Accepting Changes
Diff Tool Integration
[ModuleInitializer]
public static void Init()
{
// Verify auto-detects installed diff tools
// Override if needed:
DiffTools.UseOrder(DiffTool.VisualStudioCode, DiffTool.Rider);
}
CLI Acceptance
# Install the Verify CLI tool (one-time)
dotnet tool install -g verify.tool
# Accept all received files
verify accept
# Accept for a specific test project
verify accept --project tests/MyApp.Tests
CI Behavior
env:
DiffEngine_Disabled: true
CI/CD Integration
GitHub Actions
- name: Run tests
run: dotnet test
env:
CI: true
- name: Upload snapshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: snapshots
path: |
**/*.received.*
**/*.verified.*
When to Use Snapshot Testing
| Scenario | Use Snapshot Testing? | Why |
|---|---|---|
| Rendered HTML/emails | Yes | Catches visual regressions |
| API surfaces | Yes | Prevents accidental breaks |
| Serialization output | Yes | Validates wire format |
| Complex object graphs | Yes | Easier than manual assertions |
| Simple value checks | No | Use regular assertions |
| Business logic | No | Use explicit assertions |
| Performance tests | No | Use benchmarks |
Key Principles
- Snapshot test complex outputs, not simple values. If the expected value fits in a single
Assert.Equal, prefer that. - Scrub all non-deterministic values. Dates, GUIDs, timestamps must be scrubbed.
- Commit
.verified.txtfiles to source control. Never add.received.txtfiles. - Review snapshot diffs carefully. Accepting without review can silently approve regressions.
- Use custom converters for domain readability. Default serialization may be verbose.
- Keep snapshots focused. Use
IgnoreMemberto exclude volatile fields.
Best Practices
DO
// Use descriptive test names - they become file names
[Fact]
public Task UserRegistration_WithValidData_ReturnsConfirmation()
// Scrub dynamic values consistently
VerifierSettings.ScrubMembersWithType<Guid>();
// Use extension parameter for non-text content
await Verify(html, extension: "html");
// Keep verified files in source control
git add *.verified.*
DON'T
// Don't verify random/dynamic data without scrubbing
var order = new Order { Id = Guid.NewGuid() }; // Fails every run!
await Verify(order);
// Don't commit .received files
git add *.received.* // Wrong!
// Don't use for simple assertions
await Verify(result.Count); // Just use Assert.Equal(5, result.Count)
Agent Gotchas
- Do not forget
[UsesVerify]on the test class. Without it,Verify()calls fail at runtime. - Do not commit
.received.txtfiles. Add*.received.*to.gitignore. - Do not skip
UseParameters()in parameterized tests. All combinations write to the same file. - Do not scrub values that are part of the contract. Only scrub genuinely non-deterministic values.
- Do not use snapshot testing for rapidly evolving APIs. Wait until the API stabilizes.
- Do not hardcode Verify package versions across different test frameworks. Use version ranges (
20.*).