snapshot-testing by aaronontheweb/dotnet-skills
npx skills add https://github.com/aaronontheweb/dotnet-skills --skill snapshot-testing在以下情况下使用快照测试:
快照测试捕获输出并将其与人工批准的基线进行比较:
.received. 文件.verified. 文件.verified. 文件进行比较这可以捕获意外更改,同时通过显式批准允许有意更改。
dotnet add package Verify.Xunit
# 或其他测试框架:
dotnet add package Verify.NUnit
dotnet add package Verify.MSTest
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
在测试项目中创建 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
}
[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");
}
电子邮件测试的好处:
防止对公共 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 - 任何更改都需要显式批准。
[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
# Verify - 忽略接收文件(仅提交已验证文件)
*.received.*
# 将已验证文件视为生成文件(在 PR 差异中折叠)
*.verified.txt linguist-generated=true
*.verified.html linguist-generated=true
*.verified.json linguist-generated=true
[ModuleInitializer]
public static void Init()
{
// 在 CI 中,失败而不是启动差异工具
if (Environment.GetEnvironmentVariable("CI") == "true")
{
VerifyDiffPlex.UseDiffPlex(OutputType.Minimal);
DiffRunner.Disabled = true;
}
}
- 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)
查看 aspnetcore/transactional-emails 技能以获取完整模式:
{{variable}} 占位符的 MJML 模板这可以捕获:
每周安装量
74
仓库
GitHub 星标数
488
首次出现
2026年1月29日
安全审计
已安装于
claude-code59
codex47
opencode43
github-copilot42
gemini-cli41
cursor40
Use snapshot testing when:
Snapshot testing captures output and compares it against a human-approved baseline:
.received. file with actual output.verified. file.verified. fileThis catches unintended changes while allowing intentional changes through explicit approval.
dotnet add package Verify.Xunit
# or for other test frameworks:
dotnet add package Verify.NUnit
dotnet add package Verify.MSTest
Create a ModuleInitializer.cs in your test project:
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);
}
}
[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:
{
Id: user-123,
Name: John Doe,
Email: john@example.com,
CreatedAt: 2025-01-15T00:00:00
}
[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.
Use Verify to catch unintended changes in rendered email templates:
[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:
Prevent accidental breaking changes to public APIs:
[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:
dotnet add package PublicApiGenerator
dotnet add package Verify.Xunit
[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.
[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()
});
}
Handle timestamps, GUIDs, and other dynamic content:
[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:
{
Id: Guid_1,
CreatedAt: DateTime_1,
Total: 99.99
}
Configure in ModuleInitializer:
[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"));
}
tests/
MyApp.Tests/
Snapshots/ # All verified files
EmailTests/
WelcomeEmail.verified.html
PasswordReset.verified.html
ApiTests/
GetUser.verified.txt
EmailTests.cs
ApiTests.cs
ModuleInitializer.cs
# Verify - ignore received files (only commit verified)
*.received.*
# Treat verified files as generated (collapse in PR diffs)
*.verified.txt linguist-generated=true
*.verified.html linguist-generated=true
*.verified.json linguist-generated=true
[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;
}
}
- 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.*
| 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 |
// 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)
See the aspnetcore/transactional-emails skill for the complete pattern:
{{variable}} placeholdersThis catches:
Weekly Installs
74
Repository
GitHub Stars
488
First Seen
Jan 29, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
claude-code59
codex47
opencode43
github-copilot42
gemini-cli41
cursor40
Skills CLI 使用指南:AI Agent 技能包管理器安装与管理教程
40,000 周安装