名称: dotnet-snapshot-testing 描述: “使用Verify验证复杂输出。API响应,清理非确定性值。” 用户可调用: false
dotnet-snapshot-testing
使用Verify库进行.NET的快照(批准)测试。涵盖验证API响应、序列化对象、渲染邮件和其他复杂输出,通过比较已批准的基准文件。包括清理和过滤模式以处理非确定性值(日期、GUID、时间戳)、自定义域特定类型的转换器,以及组织和审查快照文件的策略。
版本假设: Verify 20.x+ (.NET 8.0+ 基线)。示例使用Verify.Xunit集成包;等效包适用于NUnit(Verify.NUnit)和MSTest(Verify.MSTest)。Verify自动从引用的包中发现测试框架。
范围
- Verify库设置和快照生命周期
- 清理和过滤非确定性值(日期、GUID)
- 自定义域特定类型的转换器
- 组织和审查快照文件
- 与xUnit、NUnit和MSTest的集成
范围外
- 测试项目脚手架(创建项目、包引用)-- 参见 [skill:dotnet-add-testing]
- 测试策略和测试类型决策 – 参见 [skill:dotnet-testing-strategy]
- 集成测试基础设施(WebApplicationFactory、Testcontainers)-- 参见 [skill:dotnet-integration-testing]
先决条件: 测试项目已通过[skill:dotnet-add-testing]搭建,并引用了Verify包。需要.NET 8.0+基线。
交叉引用: [skill:dotnet-testing-strategy]用于决定何时适合快照测试,[skill:dotnet-integration-testing]用于将Verify与WebApplicationFactory和Testcontainers结合使用。
设置
包
<PackageReference Include="Verify.Xunit" Version="20.*" />
<!-- 用于HTTP响应验证 -->
<PackageReference Include="Verify.Http" Version="6.*" />
模块初始化器
Verify需要每个测试程序集的一次性初始化。将以下代码放在测试项目的根文件中:
// ModuleInitializer.cs
using System.Runtime.CompilerServices;
public static class ModuleInitializer
{
[ModuleInitializer]
public static void Init() =>
VerifySourceGenerators.Initialize();
}
源代码控制
添加到.gitignore:
# Verify接收文件(测试失败)
*.received.*
添加到.gitattributes以便已验证文件差异清晰:
*.verified.txt text eol=lf
*.verified.xml text eol=lf
*.verified.json text eol=lf
基本用法
验证对象
Verify将对象序列化为JSON并与.verified.txt文件比较:
[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:
{
Id: 1,
CustomerId: cust-123,
Status: Completed,
Items: [
{
Sku: SKU-001,
Quantity: 2,
UnitPrice: 29.99
},
{
Sku: SKU-002,
Quantity: 1,
UnitPrice: 49.99
}
],
Total: 109.97
}
验证字符串和流
[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)在测试运行之间变化。清理将它们替换为稳定的占位符,以便快照保持可比性。
内置清理器
Verify包括默认启用的常见非确定性类型的清理器:
[Fact]
public Task CreateOrder_ScrubsNonDeterministicValues()
{
var order = new Order
{
Id = Guid.NewGuid(), // 清理为Guid_1
CreatedAt = DateTime.UtcNow, // 清理为DateTime_1
TrackingNumber = Guid.NewGuid().ToString() // 清理为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");
}
或跨所有验证按类型忽略:
// 在ModuleInitializer中
[ModuleInitializer]
public static void Init()
{
VerifierSettings.IgnoreMembersWithType<DateTime>();
VerifierSettings.IgnoreMembersWithType<DateTimeOffset>();
}
内联值清理
替换序列化输出中的特定模式:
[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响应
验证来自WebApplicationFactory集成测试的HTTP响应,以锁定API合同。
设置
<PackageReference Include="Verify.Http" Version="6.*" />
验证完整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);
}
}
已验证文件捕获状态码、头部和正文:
{
Status: 200 OK,
Headers: {
Content-Type: application/json; charset=utf-8
},
Body: [
{
Id: 1,
CustomerId: cust-123,
Status: Pending,
Total: 109.97
}
]
}
验证特定响应部分
[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}"));
}
}
自定义转换器
自定义转换器控制特定类型的序列化方式以进行验证。用于需要可读、稳定表示的域类型。
编写自定义转换器
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();
}
}
在模块初始化器中注册:
[ModuleInitializer]
public static void Init()
{
VerifierSettings.AddExtraSettings(settings =>
settings.Converters.Add(new MoneyConverter()));
}
复杂域类型的转换器
public class AddressConverter : WriteOnlyJsonConverter<Address>
{
public override void Write(VerifyJsonWriter writer, Address value)
{
// 单行摘要,用于紧凑快照
writer.WriteValue($"{value.Street}, {value.City}, {value.State} {value.Zip}");
}
}
public class DateRangeConverter : WriteOnlyJsonConverter<DateRange>
{
public override void Write(VerifyJsonWriter writer, DateRange value)
{
writer.WriteStartObject();
writer.WriteMember(value, value.Start.ToString("yyyy-MM-dd"), "Start");
writer.WriteMember(value, value.End.ToString("yyyy-MM-dd"), "End");
writer.WriteMember(value, value.Duration.Days, "DurationDays");
writer.WriteEndObject();
}
}
在测试中使用:
[Fact]
public Task Customer_WithAddress_MatchesSnapshot()
{
var customer = new Customer
{
Name = "Alice Johnson",
Address = new Address("123 Main St", "Springfield", "IL", "62701"),
MemberSince = new DateRange(
new DateTime(2020, 1, 15),
new DateTime(2025, 1, 15))
};
return Verify(customer);
}
产生:
{
Name: Alice Johnson,
Address: 123 Main St, Springfield, IL 62701,
MemberSince: {
Start: 2020-01-15,
End: 2025-01-15,
DurationDays: 1827
}
}
快照文件组织
默认命名
Verify基于测试类和方法命名快照文件:
TestClassName.MethodName.verified.txt
默认情况下,文件放置在测试源文件旁边。
唯一目录
将已验证文件移动到专用目录以减少杂乱:
// ModuleInitializer.cs
[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]测试,Verify将参数值附加到文件名:
[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
工作流:接受更改
当快照测试失败时,Verify创建一个.received.txt文件与.verified.txt文件并列。审查差异并接受或拒绝:
差异工具集成
Verify在测试失败时自动启动差异工具。配置首选工具:
[ModuleInitializer]
public static void Init()
{
// Verify自动检测已安装的差异工具
// 如果需要覆盖:
DiffTools.UseOrder(DiffTool.VisualStudioCode, DiffTool.Rider);
}
CLI接受
安装Verify CLI工具(一次性设置),然后审查后接受待处理更改:
# 安装Verify CLI工具(一次性)
dotnet tool install -g verify.tool
# 接受解决方案中所有接收文件
verify accept
# 接受特定测试项目
verify accept --project tests/MyApp.Tests
CI行为
在CI中,Verify应在不启动差异工具的情况下失败测试。设置环境变量:
env:
DiffEngine_Disabled: true
或在模块初始化器中:
[ModuleInitializer]
public static void Init()
{
if (Environment.GetEnvironmentVariable("CI") is not null)
{
DiffRunner.Disabled = true;
}
}
关键原则
- 快照测试复杂输出,而非简单值。 如果预期值适合单个
Assert.Equal,则优先使用该方式而非快照。快照适用于多字段对象、API响应和渲染内容。 - 清理所有非确定性值。 日期、GUID、时间戳和机器特定值必须被清理或忽略。未清理的快照会导致测试不稳定。
- 提交
.verified.txt文件到源代码控制。 这些是已批准的基准。切勿添加.received.txt文件——它们代表未批准的更改。 - 仔细审查快照差异。 未经审查接受快照更改可能无声批准回归。将快照差异视为代码审查。
- 使用自定义转换器提高域可读性。 默认JSON序列化可能对域类型冗长或不清楚。转换器产生重点突出、人类可读的快照。
- 保持快照聚焦。 仅验证重要部分。使用
IgnoreMember排除易变或无关字段,而不是验证整个对象图。
代理注意事项
- 不要忘记测试类上的
[UsesVerify]。 没有此属性,Verify()调用编译但在运行时因初始化错误失败。每个使用Verify的测试类必须有此属性。 - 不要提交
.received.txt文件。 这些代表测试失败和未批准的更改。将*.received.*添加到.gitignore以防止意外提交。 - 在参数化测试中不要跳过
UseParameters()。 没有它,所有参数组合写入同一快照文件,互相覆盖。始终使用理论数据值调用UseParameters()。 - 不要清理作为合同一部分的值。 如果API始终返回特定日期格式或已知GUID,则验证这些值而非清理它们。仅清理在运行之间真正非确定性的值。
- 不要将快照测试用于快速演变的API。 在早期开发期间,当API形状频繁变化时,快照测试会产生过多杂音。等待API稳定。
- 不要跨不同测试框架硬编码Verify包版本。
Verify.Xunit、Verify.NUnit和Verify.MSTest有独立的版本线。始终使用版本范围(如20.*),而非固定到特定版本。