verify-email-snapshots verify-email-snapshots

该技能用于对电子邮件模板进行快照测试,通过Verify库捕获HTML渲染输出并与基线对比,以检测回归问题。适用于MJML模板和各类邮件渲染器,确保邮件模板变更不会破坏视觉呈现和功能。关键词:电子邮件测试、快照测试、Verify、MJML模板、HTML渲染、回归测试、自动化测试、.NET开发。

测试 0 次安装 0 次浏览 更新于 2/26/2026

name: verify-email-snapshots description: 使用Verify对电子邮件模板进行快照测试以捕获回归问题。验证渲染的HTML输出是否与已批准的基线匹配。适用于MJML模板和任何电子邮件渲染器。 invocable: false

使用Verify对电子邮件模板进行快照测试

何时使用此技能

在以下情况使用此技能:

  • 测试电子邮件模板渲染是否存在回归问题
  • 验证MJML模板是否编译为预期的HTML
  • 在代码审查中审查电子邮件更改(差异是可视化的)
  • 确保变量替换正常工作

相关技能:

  • aspnetcore/mjml-email-templates - MJML模板编写
  • aspire/mailpit-integration - 本地测试电子邮件投递
  • testing/snapshot-testing - 通用的Verify模式

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

电子邮件模板具有以下特点:

  1. 可视化 - 微小的更改可能会在不同客户端中破坏渲染效果
  2. 难以进行单元测试 - 输出是复杂的HTML,而不是简单的值
  3. 容易出现回归问题 - 模板更改可能会产生意想不到的影响

快照测试捕获渲染的HTML,并在其发生意外更改时使测试失败。


安装

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

基本的电子邮件快照测试

[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", "December 31, 2025" }
    };

    // 执行
    var html = await renderer.RenderTemplateAsync(
        "UserInvitations/UserSignupInvitation",
        variables);

    // 断言
    await Verify(html, extension: "html");
}

首次运行时,这将创建 UserSignupInvitation_RendersCorrectly.verified.html 文件。


审查电子邮件更改

当模板更改时,测试会因差异而失败。审查选项:

1. 可视化差异工具

# 配置差异工具(一次性)
dotnet tool install -g verify.tool
verify accept  # 接受所有待处理的更改
verify review  # 打开差异工具

2. 浏览器预览

在浏览器中打开 .received.html 文件以查看实际渲染效果。

3. IDE集成

大多数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集成

缺少基线时失败

在CI中,如果不存在 .verified.html 文件,则使测试失败(防止意外接受):

// 在测试设置或 ModuleInitializer 中
VerifierSettings.ThrowOnMissingVerifiedFile();

Git配置

添加到 .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  // 危险!

// 不应该做:只测试成功路径
// 不应该做:忽略快照测试失败

工作流程

  1. 创建模板 - 编写MJML模板
  2. 编写测试 - 添加带有示例变量的快照测试
  3. 运行测试 - 首次运行创建 .verified.html
  4. 审查 - 在浏览器中打开,验证渲染效果
  5. 提交 - 将 .verified.html 包含在源代码控制中
  6. 迭代 - 更改使测试失败,审查差异,如果正确则接受

资源