verify-email-snapshots
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSnapshot Testing Email Templates with Verify
使用Verify对邮件模板进行快照测试
When to Use This Skill
何时使用此技能
Use this skill when:
- Testing email template rendering for regressions
- Validating MJML templates compile to expected HTML
- Reviewing email changes in code review (diffs are visual)
- Ensuring variable substitution works correctly
Related skills:
- - MJML template authoring
aspnetcore/mjml-email-templates - - Test email delivery locally
aspire/mailpit-integration - - General Verify patterns
testing/snapshot-testing
在以下场景使用此技能:
- 测试邮件模板渲染是否存在回归问题
- 验证MJML模板是否编译为预期的HTML
- 在代码评审中查看邮件变更(差异可视化展示)
- 确保变量替换功能正常运行
相关技能:
- - MJML模板编写
aspnetcore/mjml-email-templates - - 本地测试邮件投递
aspire/mailpit-integration - - 通用Verify模式
testing/snapshot-testing
Why Snapshot Test Emails?
为什么要对邮件进行快照测试?
Email templates are:
- Visual - Small changes can break rendering across clients
- Hard to unit test - Output is complex HTML, not simple values
- Prone to regression - Template changes can have unintended effects
Snapshot testing captures the rendered HTML and fails when it changes unexpectedly.
邮件模板具有以下特点:
- 可视化 - 微小变更可能导致不同客户端的渲染故障
- 难以单元测试 - 输出是复杂的HTML,而非简单值
- 易出现回归问题 - 模板变更可能产生意外影响
快照测试会捕获渲染后的HTML,当输出发生意外变更时测试会失败。
Installation
安装
bash
dotnet add package Verify.Xunit # or Verify.NUnit, Verify.MSTestbash
dotnet add package Verify.Xunit # 或 Verify.NUnit, Verify.MSTestBasic Email Snapshot Test
基础邮件快照测试
csharp
[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 on first run.
UserSignupInvitation_RendersCorrectly.verified.htmlcsharp
[Fact]
public async Task UserSignupInvitation_RendersCorrectly()
{
// 准备
var renderer = _services.GetRequiredService<IMjmlTemplateRenderer>();
var variables = new Dictionary<string, string>
{
{ "PreviewText", "您已受邀加入Acme Corp" },
{ "OrganizationName", "Acme Corporation" },
{ "InviteeName", "John Doe" },
{ "InviterName", "Jane Admin" },
{ "InvitationLink", "https://example.com/invite/abc123" },
{ "ExpirationDate", "2025年12月31日" }
};
// 执行
var html = await renderer.RenderTemplateAsync(
"UserInvitations/UserSignupInvitation",
variables);
// 断言
await Verify(html, extension: "html");
}首次运行时会生成文件。
UserSignupInvitation_RendersCorrectly.verified.htmlReviewing Email Changes
查看邮件变更
When a template changes, the test fails with a diff. Review options:
当模板发生变更时,测试会因差异而失败。可通过以下方式查看差异:
1. Visual Diff Tool
1. 可视化差异工具
bash
undefinedbash
undefinedConfigure diff tool (one-time)
配置差异工具(仅需一次)
dotnet tool install -g verify.tool
verify accept # Accept all pending changes
verify review # Open diff tool
undefineddotnet tool install -g verify.tool
verify accept # 接受所有待处理的变更
verify review # 打开差异工具
undefined2. Browser Preview
2. 浏览器预览
Open the file in a browser to see the actual rendering.
.received.html在浏览器中打开文件,查看实际渲染效果。
.received.html3. IDE Integration
3. IDE集成
Most IDEs show inline diffs for vs files.
.verified.html.received.html大多数IDE会在和文件之间显示行内差异。
.verified.html.received.htmlTest Each Template Variant
测试每个模板变体
Create tests for each email template to catch regressions:
csharp
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("/", "_"));
}
}为每个邮件模板创建测试,以捕捉回归问题:
csharp
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", "2025年1月15日" }
});
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("/", "_"));
}
}Scrubbing Dynamic Values
清理动态值
Some values change between test runs. Scrub them:
csharp
[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
}部分值会在测试运行之间发生变化,可对其进行清理:
csharp
[Fact]
public async Task EmailWithTimestamp_ScrubsDynamicValues()
{
var html = await _renderer.RenderTemplateAsync("Welcome", variables);
await Verify(html, extension: "html")
.ScrubLinesContaining("Generated at:")
.ScrubInlineGuids(); // 清理URL中的GUID
}Common Scrubbers
常用清理器
csharp
// 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()csharp
// 清理日期
.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()Test Fixture for Email Tests
邮件测试的测试夹具
csharp
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;
}csharp
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;
}Composer Snapshot Tests
邮件组合器快照测试
Test the full composer output including subject and metadata:
csharp
[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
});
}测试包含主题和元数据的完整组合器输出:
csharp
[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 Integration
CI集成
Fail on Missing Baseline
基线缺失时失败
In CI, fail if no file exists (prevents accidental acceptance):
.verified.htmlcsharp
// In test setup or ModuleInitializer
VerifierSettings.ThrowOnMissingVerifiedFile();在CI环境中,如果不存在文件则测试失败(防止意外接受变更):
.verified.htmlcsharp
// 在测试设置或ModuleInitializer中
VerifierSettings.ThrowOnMissingVerifiedFile();Git Configuration
Git配置
Add to to improve diff handling:
.gitattributesgitattributes
*.verified.html linguist-language=HTML
*.verified.html diff=html添加到以优化差异处理:
.gitattributesgitattributes
*.verified.html linguist-language=HTML
*.verified.html diff=htmlBest Practices
最佳实践
DO
建议做法
csharp
// 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 reviewcsharp
// 建议:测试每个模板变体
[Fact] Task WelcomeEmail_NewUser_RendersCorrectly()
[Fact] Task WelcomeEmail_InvitedUser_RendersCorrectly()
// 建议:使用描述性的测试名称
[Fact] Task PaymentReceipt_WithRefund_ShowsRefundAmount()
// 建议:持续清理动态值
.ScrubLinesContaining("Generated at:")
// 建议:接受变更前仔细查看差异
verify reviewDON'T
不建议做法
csharp
// 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 failurescsharp
// 不建议:跳过邮件测试
// 不建议:未查看就自动接受变更
verify accept --all // 危险操作!
// 不建议:仅测试正常路径
// 不建议:忽略快照测试失败Workflow
工作流程
- Create template - Write MJML template
- Write test - Add snapshot test with sample variables
- Run test - First run creates
.verified.html - Review - Open in browser, verify rendering
- Commit - Include in source control
.verified.html - Iterate - Changes fail test, review diff, accept if correct
- 创建模板 - 编写MJML模板
- 编写测试 - 添加包含示例变量的快照测试
- 运行测试 - 首次运行生成
.verified.html - 审核 - 在浏览器中打开,验证渲染效果
- 提交 - 将纳入版本控制
.verified.html - 迭代 - 变更会导致测试失败,查看差异,若正确则接受
Resources
资源
- Verify: https://github.com/VerifyTests/Verify
- Verify.Xunit: https://github.com/VerifyTests/Verify#xunit
- Diff Tools: https://github.com/VerifyTests/DiffEngine