mcpField
S
snapshot-testing

snapshot-testing

github.com/wshaddix测试与质量8671 次安装35 周前更新
代码仓库

关于 snapshot-testing

此 Skill 介绍如何在 .NET 测试项目中使用 Verify 进行快照测试,涵盖输出捕获、人工审核基线及动态数据处理,可用于对象、HTTP 响应、邮件渲染和 API 表面。

主要功能

  1. 01捕获输出并与已批准快照比较
  2. 02清理动态值并忽略易变成员
  3. 03验证对象、HTTP 响应和渲染邮件
  4. 04通过快照审核公共 API 变更
  5. 05组织快照及参数化测试文件
  6. 06使用 Verify CLI 接受审核后的变更

适用场景

  1. 01发现序列化输出中的意外变化
  2. 02审核 HTML 邮件或报告的渲染变更
  3. 03跟踪公共 API 变更以发现破坏性差异
  4. 04比较 HTTP 响应正文与标头

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill snapshot-testing

安装后在 Claude Code 中输入/snapshot-testing确认已加载。

详细介绍

使用 Verify 进行快照测试

何时使用此技能

在以下情况下使用快照测试:

  • 验证渲染输出(HTML 邮件、报告、生成的代码)
  • 审批公共 API 表面,以检测破坏性变更
  • 测试 HTTP 响应正文和标头
  • 验证序列化输出
  • 捕获复杂对象中的意外变更

什么是快照测试?

快照测试会捕获输出,并将其与经过人工审批的基准进行比较:

  1. 首次运行:测试生成包含实际输出的 .received. 文件
  2. 人工审查:开发者批准该文件,创建 .verified. 文件
  3. 后续运行:测试将输出与 .verified. 文件进行比较
  4. 检测到变更:测试失败,差异工具会显示差异以供审查

这能捕获意外变更,同时允许通过明确审批来接受有意变更。


设置

包
<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 常见问题

  1. 不要忘记在测试类上添加 [UsesVerify]。 否则,运行时调用 Verify() 会失败。
  2. 不要提交 .received.txt 文件。 将 *.received.* 添加到 .gitignore。
  3. 参数化测试中不要跳过 UseParameters()。 所有组合都会写入同一个文件。
  4. 不要清理属于契约内容的值。 只清理真正不确定的值。
  5. 不要对快速演变的 API 使用快照测试。 等 API 稳定后再使用。
  6. 不要在不同测试框架中硬编码 Verify 包版本。 使用版本范围(20.*)。

参考资料

评论

支持 Markdown