verify-email-snapshots

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Snapshot 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:
  • aspnetcore/mjml-email-templates
    - MJML template authoring
  • aspire/mailpit-integration
    - Test email delivery locally
  • testing/snapshot-testing
    - General Verify patterns

在以下场景使用此技能:
  • 测试邮件模板渲染是否存在回归问题
  • 验证MJML模板是否编译为预期的HTML
  • 在代码评审中查看邮件变更(差异可视化展示)
  • 确保变量替换功能正常运行
相关技能:
  • aspnetcore/mjml-email-templates
    - MJML模板编写
  • aspire/mailpit-integration
    - 本地测试邮件投递
  • testing/snapshot-testing
    - 通用Verify模式

Why Snapshot Test Emails?

为什么要对邮件进行快照测试?

Email templates are:
  1. Visual - Small changes can break rendering across clients
  2. Hard to unit test - Output is complex HTML, not simple values
  3. Prone to regression - Template changes can have unintended effects
Snapshot testing captures the rendered HTML and fails when it changes unexpectedly.

邮件模板具有以下特点:
  1. 可视化 - 微小变更可能导致不同客户端的渲染故障
  2. 难以单元测试 - 输出是复杂的HTML,而非简单值
  3. 易出现回归问题 - 模板变更可能产生意外影响
快照测试会捕获渲染后的HTML,当输出发生意外变更时测试会失败。

Installation

安装

bash
dotnet add package Verify.Xunit  # or Verify.NUnit, Verify.MSTest

bash
dotnet add package Verify.Xunit  # 或 Verify.NUnit, Verify.MSTest

Basic 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
UserSignupInvitation_RendersCorrectly.verified.html
on first run.

csharp
[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.html
文件。

Reviewing Email Changes

查看邮件变更

When a template changes, the test fails with a diff. Review options:
当模板发生变更时,测试会因差异而失败。可通过以下方式查看差异:

1. Visual Diff Tool

1. 可视化差异工具

bash
undefined
bash
undefined

Configure diff tool (one-time)

配置差异工具(仅需一次)

dotnet tool install -g verify.tool verify accept # Accept all pending changes verify review # Open diff tool
undefined
dotnet tool install -g verify.tool verify accept # 接受所有待处理的变更 verify review # 打开差异工具
undefined

2. Browser Preview

2. 浏览器预览

Open the
.received.html
file in a browser to see the actual rendering.
在浏览器中打开
.received.html
文件,查看实际渲染效果。

3. IDE Integration

3. IDE集成

Most IDEs show inline diffs for
.verified.html
vs
.received.html
files.

大多数IDE会在
.verified.html
.received.html
文件之间显示行内差异。

Test 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
.verified.html
file exists (prevents accidental acceptance):
csharp
// In test setup or ModuleInitializer
VerifierSettings.ThrowOnMissingVerifiedFile();
在CI环境中,如果不存在
.verified.html
文件则测试失败(防止意外接受变更):
csharp
// 在测试设置或ModuleInitializer中
VerifierSettings.ThrowOnMissingVerifiedFile();

Git Configuration

Git配置

Add to
.gitattributes
to improve diff handling:
gitattributes
*.verified.html linguist-language=HTML
*.verified.html diff=html

添加到
.gitattributes
以优化差异处理:
gitattributes
*.verified.html linguist-language=HTML
*.verified.html diff=html

Best 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 review
csharp
// 建议:测试每个模板变体
[Fact] Task WelcomeEmail_NewUser_RendersCorrectly()
[Fact] Task WelcomeEmail_InvitedUser_RendersCorrectly()

// 建议:使用描述性的测试名称
[Fact] Task PaymentReceipt_WithRefund_ShowsRefundAmount()

// 建议:持续清理动态值
.ScrubLinesContaining("Generated at:")

// 建议:接受变更前仔细查看差异
verify review

DON'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 failures

csharp
// 不建议:跳过邮件测试
// 不建议:未查看就自动接受变更
verify accept --all  // 危险操作!

// 不建议:仅测试正常路径
// 不建议:忽略快照测试失败

Workflow

工作流程

  1. Create template - Write MJML template
  2. Write test - Add snapshot test with sample variables
  3. Run test - First run creates
    .verified.html
  4. Review - Open in browser, verify rendering
  5. Commit - Include
    .verified.html
    in source control
  6. Iterate - Changes fail test, review diff, accept if correct

  1. 创建模板 - 编写MJML模板
  2. 编写测试 - 添加包含示例变量的快照测试
  3. 运行测试 - 首次运行生成
    .verified.html
  4. 审核 - 在浏览器中打开,验证渲染效果
  5. 提交 - 将
    .verified.html
    纳入版本控制
  6. 迭代 - 变更会导致测试失败,查看差异,若正确则接受

Resources

资源