Verify를 사용한 이메일 템플릿 스냅샷 테스트
이 스킬을 사용해야 할 때
다음과 같은 경우 이 스킬을 사용하세요:
- 이메일 템플릿 렌더링에서 회귀 오류를 테스트할 때
- MJML 템플릿이 예상된 HTML로 컴파일되는지 검증할 때
- 코드 리뷰에서 이메일 변경 사항을 검토할 때 (diff가 시각적)
- 변수 치환이 올바르게 작동하는지 확인할 때
관련 스킬:
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 파일이 생성됩니다.
이메일 변경 사항 검토
템플릿이 변경되면 테스트가 실패하고 diff가 표시됩니다. 검토 옵션:
1. 시각적 Diff 도구
# Diff 도구 설정 (한 번만 실행)
dotnet tool install -g verify.tool
verify accept # 모든 보류 중인 변경 사항 수락
verify review # Diff 도구 열기
2. 브라우저 미리보기
.received.html 파일을 브라우저에서 열어 실제 렌더링을 확인합니다.
3. IDE 통합
대부분의 IDE에서 .verified.html과 .received.html 파일의 인라인 diff를 보여줍니다.
각 템플릿 변형 테스트
회귀 오류를 발견하기 위해 각 이메일 템플릿에 대한 테스트를 만드세요:
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));
// 전체 이메일 객체 검증 (제목, 수신자, 본문)
await Verify(new
{
email.To,
email.Subject,
HtmlBody = email.HtmlBody // .html 확장자로 저장됨
});
}
CI 통합
기준 파일 누락 시 실패
CI에서 .verified.html 파일이 없으면 실패하도록 설정하여 우발적인 수락을 방지합니다:
// 테스트 설정 또는 ModuleInitializer에서
VerifierSettings.ThrowOnMissingVerifiedFile();
Git 구성
.gitattributes에 추가하여 diff 처리를 개선합니다:
*.verified.html linguist-language=HTML
*.verified.html diff=html
모범 사례
해야 할 일
// DO: 각 템플릿 변형을 테스트
[Fact] Task WelcomeEmail_NewUser_RendersCorrectly()
[Fact] Task WelcomeEmail_InvitedUser_RendersCorrectly()
// DO: 설명적인 테스트 이름 사용
[Fact] Task PaymentReceipt_WithRefund_ShowsRefundAmount()
// DO: 동적 값을 일관되게 스크러빙
.ScrubLinesContaining("Generated at:")
// DO: 수락 전에 diff를 신중히 검토
verify review
하지 말아야 할 일
// DON'T: 이메일 테스트를 건너뛰기
// DON'T: 검토 없이 자동으로 변경 사항을 수락
verify accept --all // 위험!
// DON'T: 행복 경로만 테스트
// DON'T: 스냅샷 테스트 실패를 무시
워크플로우
- 템플릿 생성 - MJML 템플릿을 작성합니다
- 테스트 작성 - 샘플 변수로 스냅샷 테스트를 추가합니다
- 테스트 실행 - 첫 실행 시
.verified.html이 생성됩니다 - 검토 - 브라우저에서 열어 렌더링을 확인합니다
- 커밋 -
.verified.html파일을 소스 제어에 포함합니다 - 반복 - 변경 사항이 테스트를 실패시키고, diff를 검토한 후 올바르면 수락합니다
리소스
- Verify: https://github.com/VerifyTests/Verify
- Verify.Xunit: https://github.com/VerifyTests/Verify#xunit
- Diff 도구: https://github.com/VerifyTests/DiffEngine