GUIDE

Testing OTP email flows, end to end.

The OTP test is the one everyone quarantines: it passes locally, fails on Tuesdays, and nobody trusts it enough to block a merge. That's not because email is untestable — it's because three specific mistakes keep getting copy-pasted. Here's each one, and the pattern that replaces it.

Why OTP tests flake

1. Fixed sleeps. waitForTimeout(5000) is wrong in both directions at once. When mail takes six seconds, the test fails red on a working feature. When mail takes one second, you burn four seconds per test, times every test, times every run — and someone eventually "fixes" flakiness by raising it to fifteen.

2. Reused inboxes. One shared qa@company.com mailbox means yesterday's code can match today's assertion, parallel workers steal each other's messages, and the second Tuesday run fails because the inbox has 4,000 messages in it. Any test that shares mutable state with other tests will eventually lie to you; a mailbox is mutable state.

3. Regex extraction. /\d{6}/ works until the email also contains an order number, a year, a price, or a phone number — or until marketing rewrites "Your code: 482913" as "482913 is your Acme code" and the capture group around it stops matching. The regex isn't testing your app; it's testing the current phrasing of an email template.

The pattern that doesn't

Three replacements, one per mistake: an inbox created inside the test, a long-poll the server holds open instead of a sleep, and extraction done by the receiving side and returned as a typed field.

signup.spec.ts · Playwright
test('signup sends a one-time code', async ({ page }) => {
  const inbox = await mail.createInbox({ ttlSeconds: 900 }); // fresh, private, expires itself

  await page.goto('/signup');
  await page.fill('#email', inbox.address);
  await page.click('text=Send code');

  const otp = await inbox.waitForOtp({ timeout: 30_000 }); // resolves the moment mail lands
  await page.fill('#otp', otp);
  await expect(page.locator('h1')).toHaveText('Welcome');
});

The timeout is now a ceiling, not a duration: the wait returns in whatever time the email actually takes — typically under a second — and only spends 30 seconds when something is genuinely wrong. Fast when green, informative when red. The same shape works in Cypress via cy.task and pytest via a fixture.

Why extraction beats your regex

A verification email routinely contains more than one number. The extraction that ships with the inbox scores every 4–8 digit candidate by its context — proximity to words like code, verification, OTP — and returns a ranked list with the winner as otp.best:

GET /v1/messages/:id · extracted
{
  "otp": {
    "best": "482913",
    "candidates": [
      { "value": "482913", "score": 0.97 },  // "your code is 482913"
      { "value": "2026",   "score": 0.08 }   // © 2026, correctly ignored
    ]
  },
  "links": [ { "url": "…", "kind": "verify" } ]
}

When the template changes, the scoring still finds the code — your test keeps testing the flow instead of the phrasing. Magic-link flows get the same treatment: links arrive classified as verify, reset, or unsubscribe, so asserting on "the verification link" doesn't involve parsing HTML.

The edge cases worth a test each

NOTE Every one of these needs its own inbox to be meaningful — shared-mailbox suites can't tell "old code rejected" from "wrong test's code rejected."

Local dev vs CI

None of this replaces watching emails render while you build — that's a different job, and a local SMTP-capture tool like Mailpit does it well. The split that works: capture locally, deliver in CI. Local capture gives you instant visual feedback; the CI suite receives on real domains through your real provider, which is the only way a test can catch the staging config that still points at the sandbox. The trade-offs are laid out in MailFixture vs. Mailpit.

And if your second factor arrives as a text message instead of an email, the same three mistakes flake the same way — with a phone attached. That variant has its own guide.

100 messages/mo free · inbox-per-test comes standard
Start free Read the quickstart