.NET快照测试 dotnet-snapshot-testing

这个技能用于在.NET开发中使用Verify库进行快照测试,验证复杂输出如API响应、序列化对象和渲染邮件,通过清理非确定性值(如日期、GUID)确保测试稳定性。适用于自动化测试、回归测试和API合同锁定。关键词:.NET、快照测试、Verify、API测试、测试自动化、非确定性值清理、测试框架集成。

测试 0 次安装 0 次浏览 更新于 3/6/2026

名称: 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排除易变或无关字段,而不是验证整个对象图。

代理注意事项

  1. 不要忘记测试类上的[UsesVerify] 没有此属性,Verify()调用编译但在运行时因初始化错误失败。每个使用Verify的测试类必须有此属性。
  2. 不要提交.received.txt文件。 这些代表测试失败和未批准的更改。将*.received.*添加到.gitignore以防止意外提交。
  3. 在参数化测试中不要跳过UseParameters() 没有它,所有参数组合写入同一快照文件,互相覆盖。始终使用理论数据值调用UseParameters()
  4. 不要清理作为合同一部分的值。 如果API始终返回特定日期格式或已知GUID,则验证这些值而非清理它们。仅清理在运行之间真正非确定性的值。
  5. 不要将快照测试用于快速演变的API。 在早期开发期间,当API形状频繁变化时,快照测试会产生过多杂音。等待API稳定。
  6. 不要跨不同测试框架硬编码Verify包版本。 Verify.XunitVerify.NUnitVerify.MSTest有独立的版本线。始终使用版本范围(如20.*),而非固定到特定版本。

参考资料