snapshot-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSnapshot 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:
- First run: Test generates a file with actual output
.received. - Human review: Developer approves it, creating a file
.verified. - Subsequent runs: Test compares output against file
.verified. - Changes detected: Test fails, diff tool shows differences for review
This catches unintended changes while allowing intentional changes through explicit approval.
快照测试会捕获输出内容,并将其与人工批准的基准文件进行对比:
- 首次运行:测试生成包含实际输出的文件
.received. - 人工审核:开发者批准该文件,创建基准文件
.verified. - 后续运行:测试将输出内容与基准文件进行对比
.verified. - 检测到变更:测试失败,差异工具会展示差异内容供审核
这种方式可以捕获意外变更,同时通过显式批准流程允许预期变更。
Installation
安装步骤
Add Verify Package
添加Verify包
bash
dotnet add package Verify.Xunitbash
dotnet add package Verify.Xunitor for other test frameworks:
或针对其他测试框架:
dotnet add package Verify.NUnit
dotnet add package Verify.MSTest
undefineddotnet add package Verify.NUnit
dotnet add package Verify.MSTest
undefinedConfigure ModuleInitializer
配置ModuleInitializer
Create a in your test project:
ModuleInitializer.cscsharp
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.cscsharp
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.txtjson
{
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.txtjson
{
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 - viewable in browser.
VerifyRenderedEmail.verified.htmlcsharp
[Fact]
public async Task VerifyRenderedEmail()
{
var html = await _emailRenderer.RenderAsync("Welcome", new { Name = "John" });
// 使用extension参数指定正确的文件命名
await Verify(html, extension: "html");
}运行后会生成文件,可在浏览器中查看。
VerifyRenderedEmail.verified.htmlEmail 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.Xunitcsharp
[Fact]
public Task ApproveApi()
{
var api = typeof(MyPublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}Creates with full API surface - any change requires explicit approval.
.verified.txt防止对公开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.Xunitcsharp
[Fact]
public Task ApproveApi()
{
var api = typeof(MyPublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}运行后会生成包含完整API接口的文件,任何变更都需要显式批准。
.verified.txtHTTP 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 :
ModuleInitializercsharp
[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"));
}在中配置全局规则:
ModuleInitializercsharp
[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.cstests/
MyApp.Tests/
Snapshots/ # 所有已验证文件
EmailTests/
WelcomeEmail.verified.html
PasswordReset.verified.html
ApiTests/
GetUser.verified.txt
EmailTests.cs
ApiTests.cs
ModuleInitializer.cs.gitignore
.gitignore配置
gitignore
undefinedgitignore
undefinedVerify - ignore received files (only commit verified)
Verify - 忽略received文件(仅提交verified文件)
.received.
undefined.received.
undefined.gitattributes
.gitattributes配置
gitattributes
undefinedgitattributes
undefinedTreat 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
快照测试适用场景
| Scenario | Use Snapshot Testing? | Why |
|---|---|---|
| Rendered HTML/emails | Yes | Catches visual regressions |
| API surfaces | Yes | Prevents accidental breaks |
| Serialization output | Yes | Validates wire format |
| Complex object graphs | Yes | Easier than manual assertions |
| Simple value checks | No | Use regular assertions |
| Business logic | No | Use explicit assertions |
| Performance tests | No | Use 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 skill for the complete pattern:
aspnetcore/transactional-emails- MJML templates with placeholders
{{variable}} - Render to HTML with test data
- Snapshot test the rendered output
- 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- 包含占位符的MJML模板
{{variable}} - 使用测试数据渲染为HTML
- 对渲染后的输出进行快照测试
- 在批准前使用差异工具审核变更
这种方式可以捕获:
- 变量替换错误
- CSS/布局回归问题
- 邮件客户端兼容性问题
- 意外的内容变更
Resources
参考资源
- Verify GitHub: https://github.com/VerifyTests/Verify
- Verify.Xunit: https://github.com/VerifyTests/Verify.Xunit
- ApiApprover: https://github.com/JakeGinnivan/ApiApprover
- DiffPlex Integration: https://github.com/VerifyTests/Verify.DiffPlex
- Verify GitHub:https://github.com/VerifyTests/Verify
- Verify.Xunit:https://github.com/VerifyTests/Verify.Xunit
- ApiApprover:https://github.com/JakeGinnivan/ApiApprover
- DiffPlex集成:https://github.com/VerifyTests/Verify.DiffPlex