Skip to main content

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

ScenarioEdge caseWhy tests breakApproach
Parallel CIShared inboxWrong OTP consumedMailtrap inbox per worker
Link extractHTML parsing brittleWrong hrefMailtrap API text_body / regex
Bot prefetchLink consumed before userSingle-use failExtract token; POST verify API
Template varsMissing interpolationBlank nameAssert body contains seed name
Expired linkTTL untestedUser stuckSeed expired token via Arrange
Rate limitResend spam429Probe cooldown
i18n templateLocale wrongWrong languageSeparate inbox per locale test
BCC/archiveNot in user inboxFalse negativeProbe delivery log API
Firebase/Auth OOBoobCode in linkSame as auth guideExtract query param
AttachmentInvoice PDFUntestedDownload from Mailtrap message

Mailtrap vs alternatives

Mailtrap Email TestingMailHog self-hostedSMTP mock only
CI maintenanceLow (SaaS API)Docker upkeepN/A
Parallel inboxesAPI create per testManual poolsN/A
Link/OTP extractREST APIHTTP scrapeNo real template
Template previewYesYesNo

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:

  1. Extract token from URL query (oobCode, token=) and POST to verify endpoint directly
  2. Admin/test route toggles verified flag for non-link specs (fast path)
  3. 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

StepAssert
Request resetMailtrap message received
Follow link / POST tokenNew password works via probe login
Reuse old linkProbe failure
Expired tokenProbe 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

  1. MAILTRAP_TOKEN in CI secrets—not production SMTP credentials
  2. Unique inbox email per test/worker
  3. Poll Mailtrap API with timeout—no fixed sleep
  4. Probe account state after verification—not only "success" toast
  5. Seed routes disabled in production
  6. Link tests extract token to avoid prefetch flake where needed

Anti-patterns

Anti-patternWhy it failsBetter approach
Shared support inboxCross-test OTP stealPer-run Mailtrap inbox
Assert subject onlyBody link brokenExtract and follow link
Mock SMTP in E2ETemplate bugs shipMailtrap capture
Snapshot full HTML emailLayout churnAssert key strings + link
waitForTimeout(10000)Still slow/flakyexpect.poll messages API
Skip expired link testProd lockoutArrange 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.

  1. Arrange: Mailtrap inbox for runId; seed user emailVerified false.
  2. Act: Signup UI triggers verification email.
  3. 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).

External references

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.

Start free on TestChimp · Book a demo