How to Test Transactional Email Flows
Short answer
Verification, password reset, and OTP emails break silently when templates, links, or variables regress—mocking SMTP in E2E misses real HTML and routing bugs. Use Mailtrap (or Mailosaur) API for disposable inboxes per worker, extract links or OTP codes programmatically, and probe Assert on account state after click—not inbox subject line snapshots alone.
Part of Testing Guides by integrations.
Who this is for
Any app sending transactional email: signup verification, password reset, magic links, billing receipts, MFA codes, invite flows—not marketing newsletters (different deliverability concerns).
Why testing email matters
- Blocked signup — verification link 404 or wrong domain
- Account lockout — reset email never arrives; support volume spikes
- Security — reset link reusable; OTP not expiring
- Wrong personalization —
{{name}}literal in body erodes trust - Parallel CI collisions — shared inbox receives another worker's OTP
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Parallel CI | Shared inbox | Wrong OTP consumed | Mailtrap inbox per worker |
| Link extract | HTML parsing brittle | Wrong href | Mailtrap API text_body / regex |
| Bot prefetch | Link consumed before user | Single-use fail | Extract token; POST verify API |
| Template vars | Missing interpolation | Blank name | Assert body contains seed name |
| Expired link | TTL untested | User stuck | Seed expired token via Arrange |
| Rate limit | Resend spam | 429 | Probe cooldown |
| i18n template | Locale wrong | Wrong language | Separate inbox per locale test |
| BCC/archive | Not in user inbox | False negative | Probe delivery log API |
| Firebase/Auth OOB | oobCode in link | Same as auth guide | Extract query param |
| Attachment | Invoice PDF | Untested | Download from Mailtrap message |
Mailtrap vs alternatives
| Mailtrap Email Testing | MailHog self-hosted | SMTP mock only | |
|---|---|---|---|
| CI maintenance | Low (SaaS API) | Docker upkeep | N/A |
| Parallel inboxes | API create per test | Manual pools | N/A |
| Link/OTP extract | REST API | HTTP scrape | No real template |
| Template preview | Yes | Yes | No |
Recommendation: Mailtrap Email Testing API for Playwright CI; reserve SMTP mocks for unit tests of mailer classes.
Arrange: inbox per worker
import { MailtrapClient } from 'mailtrap';
const client = new MailtrapClient({ token: process.env.MAILTRAP_TOKEN! });
async function createInbox(runId: string) {
const inbox = await client.testing.inboxes.create({
email_address: `e2e-${runId}@${process.env.MAILTRAP_INBOX_DOMAIN}`,
});
return inbox.email_address;
}
const runId = `${test.info().parallelIndex}-${Date.now()}`;
const email = await createInbox(runId);
await seedUser({ email, emailVerified: false });
Never reuse test@company.com across parallel workers.
Act: trigger email from UI
await page.goto('/signup');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: /sign up/i }).click();
await expect(page.getByText(/check your email/i)).toBeVisible();
Assert: fetch message from Mailtrap
async function waitForMessage(to: string, subjectIncludes: string) {
return await expect.poll(async () => {
const messages = await client.testing.messages.list({ to_email: to });
return messages.find(m => m.subject.includes(subjectIncludes));
}, { timeout: 30_000 }).toBeTruthy();
}
const message = await waitForMessage(email, 'Verify');
expect(message.text_body).toContain(`Hi ${displayName}`);
const verifyUrl = message.text_body!.match(/https:\/\/[^\s]+verify[^\s]+/)![0];
Prefer API-provided parsed fields when provider exposes magic link URL.
Bot prefetch mitigation
Email scanners GET links before users—consuming single-use tokens.
Mitigations:
- Extract token from URL query (
oobCode,token=) and POST to verify endpoint directly - Admin/test route toggles verified flag for non-link specs (fast path)
- Emulator paths for Firebase Auth (Firebase auth guide)
const url = new URL(verifyUrl);
const token = url.searchParams.get('token');
await request.post('/api/auth/verify-email', { data: { token } });
await expect.poll(() => probeUser(email)).toMatchObject({ emailVerified: true });
Password reset flow
| Step | Assert |
|---|---|
| Request reset | Mailtrap message received |
| Follow link / POST token | New password works via probe login |
| Reuse old link | Probe failure |
| Expired token | Probe failure; no password change |
See magic link guide for overlap.
OTP in email (non-SMS)
Extract 6-digit code with regex on text_body; fill OTP UI. Test wrong code and resend cooldown via probe.
CI checklist
MAILTRAP_TOKENin CI secrets—not production SMTP credentials- Unique inbox email per test/worker
- Poll Mailtrap API with timeout—no fixed sleep
- Probe account state after verification—not only "success" toast
- Seed routes disabled in production
- Link tests extract token to avoid prefetch flake where needed
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Shared support inbox | Cross-test OTP steal | Per-run Mailtrap inbox |
| Assert subject only | Body link broken | Extract and follow link |
| Mock SMTP in E2E | Template bugs ship | Mailtrap capture |
| Snapshot full HTML email | Layout churn | Assert key strings + link |
waitForTimeout(10000) | Still slow/flaky | expect.poll messages API |
| Skip expired link test | Prod lockout | Arrange expired token |
Example scenario
Situation: New user signs up and must verify email before accessing dashboard.
Expected outcome: Verification email arrives with correct link; after verify, probe shows emailVerified and dashboard API returns 200.
Why UI-only automation breaks: Toast says email sent but Mailtrap empty—or link works once then prefetch consumed it in CI.
- Arrange: Mailtrap inbox for runId; seed user emailVerified false.
- Act: Signup UI triggers verification email.
- Assert: Mailtrap message with link; token verify POST or goto; probe emailVerified true.
TestChimp workflow: Track email_template × trigger_event in TrueCoverage when password reset spikes without scenario.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Firebase auth — verification OOB overlap
- Magic links — passwordless email
- SMS OTP — alternate channel
- MFA — email as second factor
External references
- Mailtrap Email Testing
- Mailtrap API docs
- Amazon SES mailbox simulator — bounce/complaint testing
- Playwright APIRequestContext — verify POST without browser
Frequently asked questions
Mailtrap vs running my own mail server for tests?
Prefer Mailtrap or Mailosaur API in CI—parallel inboxes, OTP/link extraction, no Docker MailHog maintenance. Mock SMTP only in unit tests of mailer code.
How do I test email in parallel Playwright workers?
Generate unique inbox per test or worker via Mailtrap API (e2e-{parallelIndex}-{timestamp}@...). Never reuse shared company addresses.
How do I avoid bot prefetch consuming verification links?
Extract token from URL and POST to verify API instead of full link navigation; or use Admin toggle for fast CI path and separate inbox test for template.
How long should I wait for email in CI?
Poll Mailtrap messages API with expect.poll up to 30s—never fixed sleep. Fail with mailer logs if timeout.
Should I snapshot email HTML?
No—assert subject, key personalization strings, and that verify URL path is correct. Snapshots break on template redesign.
How do I test password reset end-to-end?
Trigger reset, fetch Mailtrap message, extract token, set new password, probe login with new password. Test expired and reused token negatives.
Which email flows have scenario coverage vs not?
Compare prod vs test-run across email_template × trigger_event in TrueCoverage. Run /testchimp evolve when prod triggers lack scenarios—link SmartTests via // @Scenario:.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.