Verify を使用したメールテンプレートのスナップショットテスト
このスキルを使用するタイミング
以下の場合にこのスキルを使用します:
- メールテンプレートのレンダリングでリグレッションがないかテストする
- MJML テンプレートが期待通りの HTML にコンパイルされるかを検証する
- コードレビューでメールの変更を確認する(差分は視覚的)
- 変数置換が正しく機能することを確認する
関連スキル:
aspnetcore/mjml-email-templates- MJML テンプレートの作成aspire/mailpit-integration- ローカルでのテストメール配信testing/snapshot-testing- 一般的な Verify パターン
メールのスナップショットテストを行う理由
メールテンプレートは以下の特性を持ちます:
- 視覚的 - 小さな変更がクライアント間のレンダリングを壊す可能性がある
- 単体テストが難しい - 出力は単純な値ではなく複雑な HTML
- リグレッションが発生しやすい - テンプレートの変更が意図しない影響を及ぼすことがある
スナップショットテストはレンダリングされた HTML をキャプチャし、予期せず変更された場合にテストを失敗させます。
インストール
dotnet add package Verify.Xunit # または 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");
}
これにより、初回実行時に 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("[email protected]"),
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
});
}
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 // 危険!
// 非推奨: ハッピーパスのみテストする
// 非推奨: スナップショットテストの失敗を無視する
ワークフロー
- テンプレートを作成 - MJML テンプレートを作成
- テストを書く - サンプル変数を使用してスナップショットテストを追加
- テストを実行 - 初回実行で
.verified.htmlが作成される - レビュー - ブラウザで開き、レンダリングを確認
- コミット -
.verified.htmlをソース管理に含める - 反復 - 変更があるとテストが失敗するため、差分を確認し、正しければ受け入れる
リソース
- Verify: https://github.com/VerifyTests/Verify
- Verify.Xunit: https://github.com/VerifyTests/Verify#xunit
- Diff Tools: https://github.com/VerifyTests/DiffEngine