name: 快照测试 description: 在 .NET 中使用 Verify 进行快照测试。批准 API 接口、HTTP 响应、渲染的电子邮件和序列化输出。通过人工审查的基线文件检测意外更改。 invocable: false
使用 Verify 进行快照测试
何时使用此技能
在以下情况下使用快照测试:
- 验证渲染输出(HTML 电子邮件、报告、生成的代码)
- 批准公共 API 接口以检测破坏性更改
- 测试 HTTP 响应体和头部
- 验证序列化输出
- 捕获复杂对象中的意外更改
什么是快照测试?
快照测试捕获输出并将其与人工批准的基线进行比较:
- 首次运行:测试生成包含实际输出的
.received.文件 - 人工审查:开发人员批准它,创建
.verified.文件 - 后续运行:测试将输出与
.verified.文件进行比较 - 检测到更改:测试失败,差异工具显示差异以供审查
这可以捕获意外更改,同时通过显式批准允许有意更改。
安装
添加 Verify 包
dotnet add package Verify.Xunit
# 或其他测试框架:
dotnet add package Verify.NUnit
dotnet add package Verify.MSTest
配置 ModuleInitializer
在测试项目中创建 ModuleInitializer.cs:
using System.Runtime.CompilerServices;
public static class ModuleInitializer
{
[ModuleInitializer]
public static void Init()
{
// 对已验证文件使用源文件相对路径
VerifyBase.UseProjectRelativeDirectory("Snapshots");
// 配置差异工具(可选 - 自动检测)
// DiffTools.UseOrder(DiffTool.Rider, DiffTool.VisualStudioCode);
}
}
基本用法
简单对象验证
[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:
{
Id: user-123,
Name: John Doe,
Email: john@example.com,
CreatedAt: 2025-01-15T00:00:00
}
字符串/HTML 验证
[Fact]
public async Task VerifyRenderedEmail()
{
var html = await _emailRenderer.RenderAsync("Welcome", new { Name = "John" });
// 使用扩展参数进行正确的文件命名
await Verify(html, extension: "html");
}
创建 VerifyRenderedEmail.verified.html - 可在浏览器中查看。
电子邮件模板测试
使用 Verify 捕获渲染电子邮件模板中的意外更改:
[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 接口批准
防止对公共 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 包:
dotnet add package PublicApiGenerator
dotnet add package Verify.Xunit
[Fact]
public Task ApproveApi()
{
var api = typeof(MyPublicClass).Assembly.GeneratePublicApi();
return Verify(api);
}
创建包含完整 API 接口的 .verified.txt - 任何更改都需要显式批准。
HTTP 响应测试
[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()
});
}
清理动态值
处理时间戳、GUID 和其他动态内容:
[Fact]
public Task VerifyOrder()
{
var order = new Order
{
Id = Guid.NewGuid(), // 每次运行都不同
CreatedAt = DateTime.UtcNow, // 每次运行都不同
Total = 99.99m
};
return Verify(order)
.ScrubMember("Id") // 用占位符替换
.ScrubMember("CreatedAt");
}
输出:
{
Id: Guid_1,
CreatedAt: DateTime_1,
Total: 99.99
}
全局清理
在 ModuleInitializer 中配置:
[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"));
}
文件组织
推荐结构
tests/
MyApp.Tests/
Snapshots/ # 所有已验证文件
EmailTests/
WelcomeEmail.verified.html
PasswordReset.verified.html
ApiTests/
GetUser.verified.txt
EmailTests.cs
ApiTests.cs
ModuleInitializer.cs
.gitignore
# Verify - 忽略接收文件(仅提交已验证文件)
*.received.*
.gitattributes
# 将已验证文件视为生成的文件(在 PR 差异中折叠)
*.verified.txt linguist-generated=true
*.verified.html linguist-generated=true
*.verified.json linguist-generated=true
CI/CD 集成
缺少已验证文件时失败
[ModuleInitializer]
public static void Init()
{
// 在 CI 中,失败而不是启动差异工具
if (Environment.GetEnvironmentVariable("CI") == "true")
{
VerifyDiffPlex.UseDiffPlex(OutputType.Minimal);
DiffRunner.Disabled = true;
}
}
GitHub Actions
- name: 运行测试
run: dotnet test
env:
CI: true
- name: 失败时上传快照
if: failure()
uses: actions/upload-artifact@v4
with:
name: snapshots
path: |
**/*.received.*
**/*.verified.*
何时使用快照测试
| 场景 | 是否使用快照测试? | 原因 |
|---|---|---|
| 渲染的 HTML/电子邮件 | 是 | 捕获视觉回归 |
| API 接口 | 是 | 防止意外破坏 |
| 序列化输出 | 是 | 验证有线格式 |
| 复杂对象图 | 是 | 比手动断言更容易 |
| 简单值检查 | 否 | 使用常规断言 |
| 业务逻辑 | 否 | 使用显式断言 |
| 性能测试 | 否 | 使用基准测试 |
最佳实践
应该做的
// 使用描述性测试名称 - 它们成为文件名
[Fact]
public Task UserRegistration_WithValidData_ReturnsConfirmation()
// 一致地清理动态值
VerifierSettings.ScrubMembersWithType<Guid>();
// 对非文本内容使用扩展参数
await Verify(html, extension: "html");
// 将已验证文件保留在源代码控制中
git add *.verified.*
不应该做的
// 不要在不清理的情况下验证随机/动态数据
var order = new Order { Id = Guid.NewGuid() }; // 每次运行都失败!
await Verify(order);
// 不要提交 .received 文件
git add *.received.* // 错误!
// 不要用于简单断言
await Verify(result.Count); // 只需使用 Assert.Equal(5, result.Count)
与 MJML 电子邮件测试集成
有关完整模式,请参阅 aspnetcore/transactional-emails 技能:
- 带有
{{variable}}占位符的 MJML 模板 - 使用测试数据渲染为 HTML
- 对渲染输出进行快照测试
- 在批准前在差异工具中审查更改
这可以捕获:
- 损坏的变量替换
- CSS/布局回归
- 电子邮件客户端兼容性问题
- 意外的内容更改
资源
- 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