snapshot-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Snapshot Testing with Verify

使用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

在以下场景中使用快照测试:
  • 验证渲染输出(HTML邮件、报告、生成的代码)
  • 批准公开API接口以检测破坏性变更
  • 测试HTTP响应体和响应头
  • 验证序列化输出
  • 捕获复杂对象中的意外变更

What is Snapshot Testing?

什么是快照测试?

Snapshot testing captures output and compares it against a human-approved baseline:
  1. First run: Test generates a
    .received.
    file with actual output
  2. Human review: Developer approves it, creating a
    .verified.
    file
  3. Subsequent runs: Test compares output against
    .verified.
    file
  4. Changes detected: Test fails, diff tool shows differences for review
This catches unintended changes while allowing intentional changes through explicit approval.

快照测试会捕获输出内容,并将其与人工批准的基准文件进行对比:
  1. 首次运行:测试生成包含实际输出的
    .received.
    文件
  2. 人工审核:开发者批准该文件,创建
    .verified.
    基准文件
  3. 后续运行:测试将输出内容与
    .verified.
    基准文件进行对比
  4. 检测到变更:测试失败,差异工具会展示差异内容供审核
这种方式可以捕获意外变更,同时通过显式批准流程允许预期变更

Installation

安装步骤

Add Verify Package

添加Verify包

bash
dotnet add package Verify.Xunit
bash
dotnet add package Verify.Xunit

or for other test frameworks:

或针对其他测试框架:

dotnet add package Verify.NUnit dotnet add package Verify.MSTest
undefined
dotnet add package Verify.NUnit dotnet add package Verify.MSTest
undefined

Configure ModuleInitializer

配置ModuleInitializer

Create a
ModuleInitializer.cs
in your test project:
csharp
using System.Runtime.CompilerServices;

public static class ModuleInitializer
{
    [ModuleInitializer]
    public static void Init()
    {
        // Use source-file-relative paths for verified files
        VerifyBase.UseProjectRelativeDirectory("Snapshots");

        // Configure diff tool (optional - auto-detected)
        // DiffTools.UseOrder(DiffTool.Rider, DiffTool.VisualStudioCode);
    }
}

在测试项目中创建
ModuleInitializer.cs
文件:
csharp
using System.Runtime.CompilerServices;

public static class ModuleInitializer
{
    [ModuleInitializer]
    public static void Init()
    {
        // 为已验证文件使用相对于源文件的路径
        VerifyBase.UseProjectRelativeDirectory("Snapshots");

        // 配置差异工具(可选 - 自动检测)
        // DiffTools.UseOrder(DiffTool.Rider, DiffTool.VisualStudioCode);
    }
}

Basic Usage

基础用法

Simple Object Verification

简单对象验证

csharp
[Fact]
public Task VerifyUserDto()
{
    var user = new UserDto(
        Id: "user-123",
        Name: "John Doe",
        Email: "john@example.com",
        CreatedAt: new DateTime(2025, 1, 15));

    return Verify(user);
}
Creates
VerifyUserDto.verified.txt
:
json
{
  Id: user-123,
  Name: John Doe,
  Email: john@example.com,
  CreatedAt: 2025-01-15T00:00:00
}
csharp
[Fact]
public Task VerifyUserDto()
{
    var user = new UserDto(
        Id: "user-123",
        Name: "John Doe",
        Email: "john@example.com",
        CreatedAt: new DateTime(2025, 1, 15));

    return Verify(user);
}
运行后会生成
VerifyUserDto.verified.txt
文件:
json
{
  Id: user-123,
  Name: John Doe,
  Email: john@example.com,
  CreatedAt: 2025-01-15T00:00:00
}

String/HTML Verification

字符串/HTML验证

csharp
[Fact]
public async Task VerifyRenderedEmail()
{
    var html = await _emailRenderer.RenderAsync("Welcome", new { Name = "John" });

    // Use extension parameter for proper file naming
    await Verify(html, extension: "html");
}
Creates
VerifyRenderedEmail.verified.html
- viewable in browser.

csharp
[Fact]
public async Task VerifyRenderedEmail()
{
    var html = await _emailRenderer.RenderAsync("Welcome", new { Name = "John" });

    // 使用extension参数指定正确的文件命名
    await Verify(html, extension: "html");
}
运行后会生成
VerifyRenderedEmail.verified.html
文件,可在浏览器中查看。

Email Template Testing

邮件模板测试

Use Verify to catch unintended changes in rendered email templates:
csharp
[Fact]
public async Task UserSignupInvitation_RendersCorrectly()
{
    var renderer = _services.GetRequiredService<IMjmlTemplateRenderer>();

    var variables = new Dictionary<string, string>
    {
        { "OrganizationName", "Acme Corporation" },
        { "InviteeName", "John Doe" },
        { "InviterName", "Jane Admin" },
        { "InvitationLink", "https://example.com/invite/abc123" },
        { "ExpirationDate", "December 31, 2025" }
    };

    var html = await renderer.RenderTemplateAsync(
        "UserInvitations/UserSignupInvitation",
        variables);

    await Verify(html, extension: "html");
}
Benefits for email testing:
  • Catches CSS/layout regressions
  • Detects broken template variables
  • Visual review in diff tool
  • Version control tracks email changes

使用Verify捕获渲染后的邮件模板中的意外变更:
csharp
[Fact]
public async Task UserSignupInvitation_RendersCorrectly()
{
    var renderer = _services.GetRequiredService<IMjmlTemplateRenderer>();

    var variables = new Dictionary<string, string>
    {
        { "OrganizationName", "Acme Corporation" },
        { "InviteeName", "John Doe" },
        { "InviterName", "Jane Admin" },
        { "InvitationLink", "https://example.com/invite/abc123" },
        { "ExpirationDate", "December 31, 2025" }
    };

    var html = await renderer.RenderTemplateAsync(
        "UserInvitations/UserSignupInvitation",
        variables);

    await Verify(html, extension: "html");
}
邮件测试的优势:
  • 捕获CSS/布局回归问题
  • 检测模板变量绑定错误
  • 在差异工具中进行可视化审核
  • 通过版本控制追踪邮件内容变更

API Surface Approval

API接口批准

Prevent accidental breaking changes to public APIs:
csharp
[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:
bash
dotnet add package PublicApiGenerator
dotnet add package Verify.Xunit
csharp
[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.

防止对公开API的意外破坏性变更:
csharp
[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包:
bash
dotnet add package PublicApiGenerator
dotnet add package Verify.Xunit
csharp
[Fact]
public Task ApproveApi()
{
    var api = typeof(MyPublicClass).Assembly.GeneratePublicApi();
    return Verify(api);
}
运行后会生成包含完整API接口的
.verified.txt
文件,任何变更都需要显式批准。

HTTP Response Testing

HTTP响应测试

csharp
[Fact]
public async Task GetUser_ReturnsExpectedResponse()
{
    var client = _factory.CreateClient();

    var response = await client.GetAsync("/api/users/123");

    // Verify status, headers, and body together
    await Verify(new
    {
        StatusCode = response.StatusCode,
        Headers = response.Headers
            .Where(h => h.Key.StartsWith("X-"))  // Custom headers only
            .ToDictionary(h => h.Key, h => h.Value.First()),
        Body = await response.Content.ReadAsStringAsync()
    });
}

csharp
[Fact]
public async Task GetUser_ReturnsExpectedResponse()
{
    var client = _factory.CreateClient();

    var response = await client.GetAsync("/api/users/123");

    // 同时验证状态码、响应头和响应体
    await Verify(new
    {
        StatusCode = response.StatusCode,
        Headers = response.Headers
            .Where(h => h.Key.StartsWith("X-"))  // 仅验证自定义头
            .ToDictionary(h => h.Key, h => h.Value.First()),
        Body = await response.Content.ReadAsStringAsync()
    });
}

Scrubbing Dynamic Values

动态值清理

Handle timestamps, GUIDs, and other dynamic content:
csharp
[Fact]
public Task VerifyOrder()
{
    var order = new Order
    {
        Id = Guid.NewGuid(),  // Different every run
        CreatedAt = DateTime.UtcNow,  // Different every run
        Total = 99.99m
    };

    return Verify(order)
        .ScrubMember("Id")  // Replace with placeholder
        .ScrubMember("CreatedAt");
}
Output:
json
{
  Id: Guid_1,
  CreatedAt: DateTime_1,
  Total: 99.99
}
处理时间戳、GUID及其他动态内容:
csharp
[Fact]
public Task VerifyOrder()
{
    var order = new Order
    {
        Id = Guid.NewGuid(),  // 每次运行值都不同
        CreatedAt = DateTime.UtcNow,  // 每次运行值都不同
        Total = 99.99m
    };

    return Verify(order)
        .ScrubMember("Id")  // 替换为占位符
        .ScrubMember("CreatedAt");
}
输出结果:
json
{
  Id: Guid_1,
  CreatedAt: DateTime_1,
  Total: 99.99
}

Global Scrubbing

全局清理配置

Configure in
ModuleInitializer
:
csharp
[ModuleInitializer]
public static void Init()
{
    VerifierSettings.ScrubMembersWithType<DateTime>();
    VerifierSettings.ScrubMembersWithType<DateTimeOffset>();
    VerifierSettings.ScrubMembersWithType<Guid>();

    // Scrub specific patterns
    VerifierSettings.AddScrubber(s =>
        Regex.Replace(s, @"token=[a-zA-Z0-9]+", "token=SCRUBBED"));
}

ModuleInitializer
中配置全局规则:
csharp
[ModuleInitializer]
public static void Init()
{
    VerifierSettings.ScrubMembersWithType<DateTime>();
    VerifierSettings.ScrubMembersWithType<DateTimeOffset>();
    VerifierSettings.ScrubMembersWithType<Guid>();

    // 清理特定格式内容
    VerifierSettings.AddScrubber(s =>
        Regex.Replace(s, @"token=[a-zA-Z0-9]+", "token=SCRUBBED"));
}

File Organization

文件组织

Recommended Structure

推荐结构

tests/
  MyApp.Tests/
    Snapshots/           # All verified files
      EmailTests/
        WelcomeEmail.verified.html
        PasswordReset.verified.html
      ApiTests/
        GetUser.verified.txt
    EmailTests.cs
    ApiTests.cs
    ModuleInitializer.cs
tests/
  MyApp.Tests/
    Snapshots/           # 所有已验证文件
      EmailTests/
        WelcomeEmail.verified.html
        PasswordReset.verified.html
      ApiTests/
        GetUser.verified.txt
    EmailTests.cs
    ApiTests.cs
    ModuleInitializer.cs

.gitignore

.gitignore配置

gitignore
undefined
gitignore
undefined

Verify - ignore received files (only commit verified)

Verify - 忽略received文件(仅提交verified文件)

.received.
undefined
.received.
undefined

.gitattributes

.gitattributes配置

gitattributes
undefined
gitattributes
undefined

Treat verified files as generated (collapse in PR diffs)

将verified文件标记为生成文件(在PR差异中折叠显示)

*.verified.txt linguist-generated=true *.verified.html linguist-generated=true *.verified.json linguist-generated=true

---
*.verified.txt linguist-generated=true *.verified.html linguist-generated=true *.verified.json linguist-generated=true

---

CI/CD Integration

CI/CD集成

Fail on Missing Verified Files

缺失基准文件时直接失败

csharp
[ModuleInitializer]
public static void Init()
{
    // In CI, fail instead of launching diff tool
    if (Environment.GetEnvironmentVariable("CI") == "true")
    {
        VerifyDiffPlex.UseDiffPlex(OutputType.Minimal);
        DiffRunner.Disabled = true;
    }
}
csharp
[ModuleInitializer]
public static void Init()
{
    // 在CI环境中,直接失败而非启动差异工具
    if (Environment.GetEnvironmentVariable("CI") == "true")
    {
        VerifyDiffPlex.UseDiffPlex(OutputType.Minimal);
        DiffRunner.Disabled = true;
    }
}

GitHub Actions

GitHub Actions配置

yaml
- 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.*

yaml
- name: 运行测试
  run: dotnet test
  env:
    CI: true

- name: 测试失败时上传快照文件
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: snapshots
    path: |
      **/*.received.*
      **/*.verified.*

When to Use Snapshot Testing

快照测试适用场景

ScenarioUse Snapshot Testing?Why
Rendered HTML/emailsYesCatches visual regressions
API surfacesYesPrevents accidental breaks
Serialization outputYesValidates wire format
Complex object graphsYesEasier than manual assertions
Simple value checksNoUse regular assertions
Business logicNoUse explicit assertions
Performance testsNoUse benchmarks

场景是否使用快照测试?原因
渲染后的HTML/邮件捕获视觉回归问题
API接口防止意外破坏性变更
序列化输出验证传输格式
复杂对象图比手动断言更简便
简单值校验使用常规断言即可
业务逻辑使用显式断言
性能测试使用基准测试工具

Best Practices

最佳实践

DO

建议做法

csharp
// 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.*
csharp
// 使用描述性测试名称 - 名称会作为文件名
[Fact]
public Task UserRegistration_WithValidData_ReturnsConfirmation()

// 统一清理动态值
VerifierSettings.ScrubMembersWithType<Guid>();

// 为非文本内容使用extension参数
await Verify(html, extension: "html");

// 将已验证文件纳入版本控制
git add *.verified.*

DON'T

不建议做法

csharp
// 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)

csharp
// 不要在未清理的情况下验证随机/动态数据
var order = new Order { Id = Guid.NewGuid() };  // 每次运行都会失败!
await Verify(order);

// 不要提交.received文件
git add *.received.*  // 错误做法!

// 不要将其用于简单断言
await Verify(result.Count);  // 直接使用Assert.Equal(5, result.Count)即可

Integration with MJML Email Testing

与MJML邮件测试的集成

See the
aspnetcore/transactional-emails
skill for the complete pattern:
  1. MJML templates with
    {{variable}}
    placeholders
  2. Render to HTML with test data
  3. Snapshot test the rendered output
  4. Review changes in diff tool before approving
This catches:
  • Broken variable substitution
  • CSS/layout regressions
  • Email client compatibility issues
  • Unintended content changes

查看
aspnetcore/transactional-emails
技能了解完整流程:
  1. 包含
    {{variable}}
    占位符的MJML模板
  2. 使用测试数据渲染为HTML
  3. 对渲染后的输出进行快照测试
  4. 在批准前使用差异工具审核变更
这种方式可以捕获:
  • 变量替换错误
  • CSS/布局回归问题
  • 邮件客户端兼容性问题
  • 意外的内容变更

Resources

参考资源