关于 snapshot-testing
此 Skill 介绍如何在 .NET 测试项目中使用 Verify 进行快照测试,涵盖输出捕获、人工审核基线及动态数据处理,可用于对象、HTTP 响应、邮件渲染和 API 表面。
主要功能
- 01捕获输出并与已批准快照比较
- 02清理动态值并忽略易变成员
- 03验证对象、HTTP 响应和渲染邮件
- 04通过快照审核公共 API 变更
- 05组织快照及参数化测试文件
- 06使用 Verify CLI 接受审核后的变更
适用场景
- 01发现序列化输出中的意外变化
- 02审核 HTML 邮件或报告的渲染变更
- 03跟踪公共 API 变更以发现破坏性差异
- 04比较 HTTP 响应正文与标头
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill snapshot-testing安装后在 Claude Code 中输入/snapshot-testing确认已加载。
详细介绍
使用 Verify 进行快照测试
何时使用此技能
在以下情况下使用快照测试:
- 验证渲染输出(HTML 邮件、报告、生成的代码)
- 审批公共 API 表面,以检测破坏性变更
- 测试 HTTP 响应正文和标头
- 验证序列化输出
- 捕获复杂对象中的意外变更
什么是快照测试?
快照测试会捕获输出,并将其与经过人工审批的基准进行比较:
- 首次运行:测试生成包含实际输出的
.received.文件 - 人工审查:开发者批准该文件,创建
.verified.文件 - 后续运行:测试将输出与
.verified.文件进行比较 - 检测到变更:测试失败,差异工具会显示差异以供审查
这能捕获意外变更,同时允许通过明确审批来接受有意变更。
设置
包
<PackageReference Include="Verify.Xunit" Version="20.*" />
<PackageReference Include="Verify.Http" Version="6.*" />
模块初始化器
Verify 要求在每个测试程序集内进行一次性初始化:
// 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;
}
}
}
源代码管理
添加到 .gitignore:
# Verify received files (test failures)
*.received.*
添加到 .gitattributes:
*.verified.txt text eol=lf
*.verified.xml text eol=lf
*.verified.json text eol=lf
*.verified.html text eol=lf
基本用法
验证对象
[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);
}
}
会创建 OrderSerializationTests.Serialize_CompletedOrder_MatchesSnapshot.verified.txt。
验证字符串和流
[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");
}
清理和筛选
非确定性值(日期、GUID、自动递增 ID)会在不同测试运行之间发生变化。清理操作会将它们替换为稳定的占位符。
内置清理器
[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);
}
生成稳定的输出:
{
Id: Guid_1,
CreatedAt: DateTime_1,
TrackingNumber: Guid_2
}
自定义清理器
[Fact]
public Task AuditLog_ScrubsTimestampsAndMachineNames()
{
var log = auditService.GetRecentEntries();
return Verify(log)
.ScrubLinesWithReplace(line =>
Regex.Replace(line, @"Machine:\s+\w+", "Machine: Scrubbed"))
.ScrubLinesContaining("CorrelationId:");
}
忽略成员
[Fact]
public Task OrderSnapshot_IgnoresVolatileFields()
{
var order = orderService.CreateOrder(request);
return Verify(order)
.IgnoreMember("CreatedAt")
.IgnoreMember("UpdatedAt")
.IgnoreMember("ETag");
}
清理内联值
[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}"));
}
验证 HTTP 响应
完整 HTTP 响应
[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);
}
}
特定响应部分
[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");
}
验证渲染后的邮件
通过验证渲染后的 HTML 输出,对邮件模板进行快照测试:
[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}"));
}
}
邮件测试的优势:
- 捕获 CSS/布局回归
- 检测模板变量是否损坏
- 在差异工具中进行可视化审查
- 通过版本控制追踪邮件变更
API 表面审批
防止公共 API 意外发生破坏性变更:
[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);
}
也可以使用专用的 ApiApprover 包:
<PackageReference Include="PublicApiGenerator" />
<PackageReference Include="Verify.Xunit" />
[Fact]
public Task ApproveApi()
{
var api = typeof(MyPublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}
会创建包含完整 API 表面的 .verified.txt 文件;任何变更都需要明确审批。
自定义转换器
控制特定类型的序列化方式,以便进行验证:
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}");
}
}
在模块初始化器中注册:
[ModuleInitializer]
public static void Init()
{
VerifierSettings.AddExtraSettings(settings =>
{
settings.Converters.Add(new MoneyConverter());
settings.Converters.Add(new AddressConverter());
});
}
快照文件组织
独立目录
将已验证文件移到专用目录:
[ModuleInitializer]
public static void Init()
{
Verifier.DerivePathInfo(
(sourceFile, projectDirectory, type, method) =>
new PathInfo(
directory: Path.Combine(projectDirectory, "Snapshots"),
typeName: type.Name,
methodName: method.Name));
}
参数化测试
对于 [Theory] 测试,使用 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);
}
会创建单独的文件:
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
工作流:接受变更
差异工具集成
[ModuleInitializer]
public static void Init()
{
// Verify auto-detects installed diff tools
// Override if needed:
DiffTools.UseOrder(DiffTool.VisualStudioCode, DiffTool.Rider);
}
CLI 接受变更
# 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 行为
env:
DiffEngine_Disabled: true
CI/CD 集成
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.*
何时使用快照测试
| 场景 | 使用快照测试? | 原因 |
|---|---|---|
| 渲染后的 HTML/邮件 | 是 | 捕获视觉回归 |
| API 表面 | 是 | 防止意外的破坏性变更 |
| 序列化输出 | 是 | 验证传输格式 |
| 复杂对象图 | 是 | 比手动断言更轻松 |
| 简单值检查 | 否 | 使用常规断言 |
| 业务逻辑 | 否 | 使用显式断言 |
| 性能测试 | 否 | 使用基准测试 |
核心原则
- 对复杂输出进行快照测试,而非简单值。 如果预期值可以放进单个
Assert.Equal,优先使用它。 - 清理所有非确定性值。 必须清理日期、GUID 和时间戳。
- 将
.verified.txt文件提交到源代码管理。 切勿添加.received.txt文件。 - 仔细审查快照差异。 未经审查就接受,可能会在不知不觉中批准回归。
- 使用自定义转换器提升领域可读性。 默认序列化可能会很冗长。
- 让快照保持聚焦。 使用
IgnoreMember排除易变字段。
最佳实践
应该做
// 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 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 常见问题
- 不要忘记在测试类上添加
[UsesVerify]。 否则,运行时调用Verify()会失败。 - 不要提交
.received.txt文件。 将*.received.*添加到.gitignore。 - 参数化测试中不要跳过
UseParameters()。 所有组合都会写入同一个文件。 - 不要清理属于契约内容的值。 只清理真正不确定的值。
- 不要对快速演变的 API 使用快照测试。 等 API 稳定后再使用。
- 不要在不同测试框架中硬编码 Verify 包版本。 使用版本范围(
20.*)。
参考资料
评论
支持 Markdown