E2E sign-up flows with real emails.
Stop mocking email in your end-to-end tests. Use a real inbox per test, real DKIM, real waiters — and zero cleanup.
- Concurrent waiters
- Ephemeral by default
- Works in any CI
The problem with mocking email
End-to-end tests exist to catch the integration bugs that unit tests miss. The moment you mock email, you stop catching the most common integration bugs in the first place: a missing DKIM record, a misconfigured From header, a transactional provider quietly throttling, a magic-link URL with the wrong host. The test passes; production does not.
The alternative — pointing the signup form at a shared QA mailbox — solves the integration problem and creates four new ones: parallel tests step on each other, the inbox accumulates years of junk, anyone can read anyone else's verification codes, and tearing down state between runs becomes its own ten-line cleanup script.
What you actually want is one inbox per test, on a real domain, with a waiter that resolves the moment the email lands. That is the entire shape of the catchotp API.
The three primitives you need
Per-test inboxes
One address per test, scoped by a Playwright or Cypress fixture. Auto-expire by TTL or delete explicitly in teardown.
Concurrent waiters
messages.waitFor() with
subject regex or sender filter. Multiple tests block on different inboxes
simultaneously without contention.
Structured message body
Every message returns parsed text, HTML, headers, deduped links, and detected OTPs. Pull the magic-link URL by hostname; never write a regex over raw HTML.
Mocked vs real email in E2E
| Concern | Mocked email | Real email (catchotp) |
|---|---|---|
| DKIM / SPF coverage | Not exercised | Exercised end-to-end |
| Provider rate limits | Hidden | Surfaced |
| Parallel test isolation | Manual | One inbox per test, automatic |
| Test latency | ~instant | ~700ms p50 |
| Cleanup burden | Reset mock state | TTL handles it |
Playwright fixture + magic-link test
Define an inbox fixture once; reuse it across every test that needs email. Below: a Playwright fixture, a complete magic-link test, and the equivalent Cypress version.
// tests/fixtures/inbox.ts
import { test as base } from '@playwright/test';
import { CatchOTP, type Inbox } from '@catchotp/sdk';
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use) => {
const otp = new CatchOTP({ apiKey: process.env.CATCHOTP_KEY! });
const inbox = await otp.inboxes.create({ mode: 'ephemeral', ttlMinutes: 15 });
await use(inbox);
// No teardown needed — TTL handles it. Delete explicitly if you want it gone now.
await otp.inboxes.delete(inbox.id).catch(() => {});
},
});
export { expect } from '@playwright/test'; E2E testing FAQ
Why not just use a Mailpit or MailHog container?
Can I run E2E tests in parallel?
What about cleanup between runs?
How do I extract a magic-link URL?
Does this work with Vercel / Netlify preview deploys?
Related use cases
OTP testing
Drop-in waiters for one-time codes in Cypress, Playwright, Jest, and pytest.
QA automation
Email-aware QA pipelines without an SMTP server to babysit.
Read the SDK reference or jump straight to pricing.
Wire it into your suite this afternoon.
Free tier includes 5 inboxes and 1,000 messages a month — enough for most CI pipelines.