verify-email-snapshots by aaronontheweb/dotnet-skills
npx skills add https://github.com/aaronontheweb/dotnet-skills --skill verify-email-snapshots在以下情况下使用此技能:
相关技能:
aspnetcore/mjml-email-templates - MJML 模板编写aspire/mailpit-integration - 本地测试电子邮件发送testing/snapshot-testing - 通用的 Verify 模式电子邮件模板具有以下特点:
快照测试捕获渲染后的 HTML,并在其发生意外更改时使测试失败。
dotnet add package Verify.Xunit # 或 Verify.NUnit, Verify.MSTest
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
[Fact]
public async Task UserSignupInvitation_RendersCorrectly()
{
// 准备
var renderer = _services.GetRequiredService<IMjmlTemplateRenderer>();
var variables = new Dictionary<string, string>
{
{ "PreviewText", "You've been invited to join Acme Corp" },
{ "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");
}
首次运行时,这会创建 UserSignupInvitation_RendersCorrectly.verified.html 文件。
当模板发生更改时,测试会因差异而失败。审查选项:
# 配置差异工具(一次性操作)
dotnet tool install -g verify.tool
verify accept # 接受所有待处理的更改
verify review # 打开差异工具
在浏览器中打开 .received.html 文件以查看实际渲染效果。
大多数 IDE 会显示 .verified.html 与 .received.html 文件的内联差异。
为每个电子邮件模板创建测试以捕获回归问题:
public class EmailTemplateSnapshotTests : IClassFixture<EmailTestFixture>
{
private readonly IMjmlTemplateRenderer _renderer;
public EmailTemplateSnapshotTests(EmailTestFixture fixture)
{
_renderer = fixture.Services.GetRequiredService<IMjmlTemplateRenderer>();
}
[Fact]
public async Task WelcomeEmail_NewUser() =>
await VerifyTemplate("Welcome/NewUser", new Dictionary<string, string>
{
{ "UserName", "John Doe" },
{ "LoginUrl", "https://example.com/login" }
});
[Fact]
public async Task WelcomeEmail_InvitedUser() =>
await VerifyTemplate("Welcome/InvitedUser", new Dictionary<string, string>
{
{ "UserName", "John Doe" },
{ "InviterName", "Jane Admin" },
{ "OrganizationName", "Acme Corp" }
});
[Fact]
public async Task PasswordReset() =>
await VerifyTemplate("PasswordReset/PasswordReset", new Dictionary<string, string>
{
{ "UserName", "John Doe" },
{ "ResetLink", "https://example.com/reset/abc123" },
{ "ExpirationMinutes", "30" }
});
[Fact]
public async Task PaymentReceipt() =>
await VerifyTemplate("Billing/PaymentReceipt", new Dictionary<string, string>
{
{ "UserName", "John Doe" },
{ "Amount", "$10.00" },
{ "InvoiceNumber", "INV-2025-001" },
{ "Date", "January 15, 2025" }
});
private async Task VerifyTemplate(
string templateName,
Dictionary<string, string> variables)
{
var html = await _renderer.RenderTemplateAsync(templateName, variables);
await Verify(html, extension: "html")
.UseMethodName(templateName.Replace("/", "_"));
}
}
某些值在测试运行之间会发生变化。清理它们:
[Fact]
public async Task EmailWithTimestamp_ScrubsDynamicValues()
{
var html = await _renderer.RenderTemplateAsync("Welcome", variables);
await Verify(html, extension: "html")
.ScrubLinesContaining("Generated at:")
.ScrubInlineGuids(); // 清理 URL 中的 GUID
}
// 清理日期
.ScrubLinesContaining("Date:")
.AddScrubber(s => Regex.Replace(s, @"\d{4}-\d{2}-\d{2}", "SCRUBBED-DATE"))
// 清理带有令牌的 URL
.AddScrubber(s => Regex.Replace(s, @"token=[a-zA-Z0-9]+", "token=SCRUBBED"))
// 清理 GUID
.ScrubInlineGuids()
public class EmailTestFixture : IAsyncLifetime
{
public IServiceProvider Services { get; private set; } = null!;
public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["SiteUrl"] = "https://example.com"
})
.Build());
services.AddSingleton<IMjmlTemplateRenderer, MjmlTemplateRenderer>();
Services = services.BuildServiceProvider();
await Task.CompletedTask;
}
public Task DisposeAsync() => Task.CompletedTask;
}
测试完整的组合器输出,包括主题和元数据:
[Fact]
public async Task SignupInvitation_ComposesCorrectEmail()
{
var composer = _services.GetRequiredService<IUserEmailComposer>();
var email = await composer.ComposeSignupInvitationAsync(
recipientEmail: new EmailAddress("john@example.com"),
recipientName: new PersonName("John Doe"),
inviterName: new PersonName("Jane Admin"),
organizationName: new OrganizationName("Acme Corp"),
invitationUrl: new AbsoluteUri("https://example.com/invite/abc123"),
expiresAt: new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero));
// 验证完整的电子邮件对象(收件人、主题、正文)
await Verify(new
{
email.To,
email.Subject,
HtmlBody = email.HtmlBody // 将存储为 .html 扩展名
});
}
在 CI 中,如果不存在 .verified.html 文件则使测试失败(防止意外接受):
// 在测试设置或 ModuleInitializer 中
VerifierSettings.ThrowOnMissingVerifiedFile();
添加到 .gitattributes 以改进差异处理:
*.verified.html linguist-language=HTML
*.verified.html diff=html
// 应该:测试每个模板变体
[Fact] Task WelcomeEmail_NewUser_RendersCorrectly()
[Fact] Task WelcomeEmail_InvitedUser_RendersCorrectly()
// 应该:使用描述性的测试名称
[Fact] Task PaymentReceipt_WithRefund_ShowsRefundAmount()
// 应该:一致地清理动态值
.ScrubLinesContaining("Generated at:")
// 应该:在接受前仔细审查差异
verify review
// 不应该:跳过电子邮件测试
// 不应该:未经审查就自动接受更改
verify accept --all // 危险!
// 不应该:只测试正常路径
// 不应该:忽略快照测试失败
.verified.html.verified.html 包含在源代码控制中每周安装次数
67
代码仓库
GitHub 星标数
488
首次出现
2026年2月2日
安全审计
已安装于
claude-code53
codex42
opencode39
gemini-cli38
github-copilot37
cursor37
Use this skill when:
Related skills:
aspnetcore/mjml-email-templates - MJML template authoringaspire/mailpit-integration - Test email delivery locallytesting/snapshot-testing - General Verify patternsEmail templates are:
Snapshot testing captures the rendered HTML and fails when it changes unexpectedly.
dotnet add package Verify.Xunit # or Verify.NUnit, Verify.MSTest
[Fact]
public async Task UserSignupInvitation_RendersCorrectly()
{
// Arrange
var renderer = _services.GetRequiredService<IMjmlTemplateRenderer>();
var variables = new Dictionary<string, string>
{
{ "PreviewText", "You've been invited to join Acme Corp" },
{ "OrganizationName", "Acme Corporation" },
{ "InviteeName", "John Doe" },
{ "InviterName", "Jane Admin" },
{ "InvitationLink", "https://example.com/invite/abc123" },
{ "ExpirationDate", "December 31, 2025" }
};
// Act
var html = await renderer.RenderTemplateAsync(
"UserInvitations/UserSignupInvitation",
variables);
// Assert
await Verify(html, extension: "html");
}
This creates UserSignupInvitation_RendersCorrectly.verified.html on first run.
When a template changes, the test fails with a diff. Review options:
# Configure diff tool (one-time)
dotnet tool install -g verify.tool
verify accept # Accept all pending changes
verify review # Open diff tool
Open the .received.html file in a browser to see the actual rendering.
Most IDEs show inline diffs for .verified.html vs .received.html files.
Create tests for each email template to catch regressions:
public class EmailTemplateSnapshotTests : IClassFixture<EmailTestFixture>
{
private readonly IMjmlTemplateRenderer _renderer;
public EmailTemplateSnapshotTests(EmailTestFixture fixture)
{
_renderer = fixture.Services.GetRequiredService<IMjmlTemplateRenderer>();
}
[Fact]
public async Task WelcomeEmail_NewUser() =>
await VerifyTemplate("Welcome/NewUser", new Dictionary<string, string>
{
{ "UserName", "John Doe" },
{ "LoginUrl", "https://example.com/login" }
});
[Fact]
public async Task WelcomeEmail_InvitedUser() =>
await VerifyTemplate("Welcome/InvitedUser", new Dictionary<string, string>
{
{ "UserName", "John Doe" },
{ "InviterName", "Jane Admin" },
{ "OrganizationName", "Acme Corp" }
});
[Fact]
public async Task PasswordReset() =>
await VerifyTemplate("PasswordReset/PasswordReset", new Dictionary<string, string>
{
{ "UserName", "John Doe" },
{ "ResetLink", "https://example.com/reset/abc123" },
{ "ExpirationMinutes", "30" }
});
[Fact]
public async Task PaymentReceipt() =>
await VerifyTemplate("Billing/PaymentReceipt", new Dictionary<string, string>
{
{ "UserName", "John Doe" },
{ "Amount", "$10.00" },
{ "InvoiceNumber", "INV-2025-001" },
{ "Date", "January 15, 2025" }
});
private async Task VerifyTemplate(
string templateName,
Dictionary<string, string> variables)
{
var html = await _renderer.RenderTemplateAsync(templateName, variables);
await Verify(html, extension: "html")
.UseMethodName(templateName.Replace("/", "_"));
}
}
Some values change between test runs. Scrub them:
[Fact]
public async Task EmailWithTimestamp_ScrubsDynamicValues()
{
var html = await _renderer.RenderTemplateAsync("Welcome", variables);
await Verify(html, extension: "html")
.ScrubLinesContaining("Generated at:")
.ScrubInlineGuids(); // Scrubs GUIDs in URLs
}
// Scrub dates
.ScrubLinesContaining("Date:")
.AddScrubber(s => Regex.Replace(s, @"\d{4}-\d{2}-\d{2}", "SCRUBBED-DATE"))
// Scrub URLs with tokens
.AddScrubber(s => Regex.Replace(s, @"token=[a-zA-Z0-9]+", "token=SCRUBBED"))
// Scrub GUIDs
.ScrubInlineGuids()
public class EmailTestFixture : IAsyncLifetime
{
public IServiceProvider Services { get; private set; } = null!;
public async Task InitializeAsync()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["SiteUrl"] = "https://example.com"
})
.Build());
services.AddSingleton<IMjmlTemplateRenderer, MjmlTemplateRenderer>();
Services = services.BuildServiceProvider();
await Task.CompletedTask;
}
public Task DisposeAsync() => Task.CompletedTask;
}
Test the full composer output including subject and metadata:
[Fact]
public async Task SignupInvitation_ComposesCorrectEmail()
{
var composer = _services.GetRequiredService<IUserEmailComposer>();
var email = await composer.ComposeSignupInvitationAsync(
recipientEmail: new EmailAddress("john@example.com"),
recipientName: new PersonName("John Doe"),
inviterName: new PersonName("Jane Admin"),
organizationName: new OrganizationName("Acme Corp"),
invitationUrl: new AbsoluteUri("https://example.com/invite/abc123"),
expiresAt: new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero));
// Verify the full email object (subject, to, body)
await Verify(new
{
email.To,
email.Subject,
HtmlBody = email.HtmlBody // Will be stored as .html extension
});
}
In CI, fail if no .verified.html file exists (prevents accidental acceptance):
// In test setup or ModuleInitializer
VerifierSettings.ThrowOnMissingVerifiedFile();
Add to .gitattributes to improve diff handling:
*.verified.html linguist-language=HTML
*.verified.html diff=html
// DO: Test each template variant
[Fact] Task WelcomeEmail_NewUser_RendersCorrectly()
[Fact] Task WelcomeEmail_InvitedUser_RendersCorrectly()
// DO: Use descriptive test names
[Fact] Task PaymentReceipt_WithRefund_ShowsRefundAmount()
// DO: Scrub dynamic values consistently
.ScrubLinesContaining("Generated at:")
// DO: Review diffs carefully before accepting
verify review
// DON'T: Skip email testing
// DON'T: Auto-accept changes without review
verify accept --all // Dangerous!
// DON'T: Test only happy path
// DON'T: Ignore snapshot test failures
.verified.html.verified.html in source controlWeekly Installs
67
Repository
GitHub Stars
488
First Seen
Feb 2, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
claude-code53
codex42
opencode39
gemini-cli38
github-copilot37
cursor37
通过 LiteLLM 代理让 Claude Code 对接 GitHub Copilot 运行 | 高级变通方案指南
40,000 周安装