DEV Community

KIPDEV
KIPDEV

Posted on Originally published at inboxsink.com

Testing email verification in Playwright without waitForTimeout

Disclosure: I built inboxsink, the inbox API used in this post. The original, kept up to date, is on inboxsink.com.

Your signup form sends a six-digit code, and the test has to type it in. Most suites deal with that using await page.waitForTimeout(5000) and a look into a shared inbox. It holds until the mail provider has a slow minute, or until two workers read the same inbox.

This post shows the version I run instead. Each test gets its own address, the test blocks until the email is actually stored, and the code comes back already pulled out of the message. Everything below ran on 13 September 2026 against a small signup app sending real email through Mailjet.

What the run looked like

Three signups in parallel on three workers, plus two magic-link sign-ins:

Running 5 tests using 3 workers

#1 1f6f1624c080@mailhusk.com code=360492 after 1619 ms
#2 59694fd86195@mailhusk.com code=102927 after 1622 ms
  ✓  tests/signup.spec.js › a new user can sign up with an emailed code #1 (2.9s)
  ✓  tests/signup.spec.js › a new user can sign up with an emailed code #2 (2.9s)
magic link extracted: http://localhost:4173/magic-link?token=ba0d7a21230255a664c02a9681dc8dc1
  ✓  tests/magic.spec.js › a user can sign in with a magic link (3.5s)
#3 da6140eb68be@mailhusk.com code=330805 after 1285 ms
short link — extracted link field: null
  ✓  tests/signup.spec.js › a new user can sign up with an emailed code #3 (1.8s)
  ✓  tests/magic.spec.js › a link with no keyword in its URL is not extracted: parse the body (1.8s)

  5 passed (6.1s)
Enter fullscreen mode Exit fullscreen mode

The figure to look at is the time between the click and the code: 1.3 to 1.6 seconds, sending included. A fixed five-second sleep wastes three to four seconds per test when delivery is quick, and fails when it isn't.

The test

import { test, expect } from '@playwright/test';
import { InboxSink } from 'inboxsink';

const sink = new InboxSink(); // reads INBOXSINK_API_KEY

test('a new user can sign up with an emailed code', async ({ page }) => {
  const inbox = await sink.createInbox({ ttlSeconds: 900 });

  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Create account' }).click();

  const code = await sink.waitForOtp(inbox.id, { timeoutMs: 60_000 });

  await page.getByLabel('Verification code').fill(code);
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome aboard' })).toBeVisible();

  await sink.deleteInbox(inbox.id);
});
Enter fullscreen mode Exit fullscreen mode
  • createInbox returns an address nobody else is using. With ttlSeconds: 900 the inbox deletes itself after 15 minutes, so a crashed test leaves nothing behind for long.
  • waitForOtp keeps a single HTTP request open until the message is stored, then returns the code. No polling loop in the test.
  • If an email arrives without a code, waitForOtp throws and names it (Message "Welcome to Acme" arrived but contains no verification code) instead of submitting an empty field.

Keep the test timeout above the wait

Playwright gives each test 30 seconds by default, and waitForOtp also waits 30 seconds by default. The page load and clicks have already used part of the test's budget, so when no email comes, the test timeout fires first. I checked: the report says Test timeout of 30000ms exceeded, which tells you nothing about the email.

// playwright.config.js
export default defineConfig({
  timeout: 90_000, // above the longest waitForOtp in the suite
});
Enter fullscreen mode Exit fullscreen mode

Why one inbox per test

With a shared inbox — a public Mailinator name, one Gmail account for the whole suite — two workers that sign up in the same second both read "the latest message", and one of them types the other's code. With a single worker you never see it. In the run above, three workers got three addresses and three different codes. Nothing to lock, nothing to clear.

Magic links, and one limit

const link = await sink.waitForLink(inbox.id, { timeoutMs: 60_000 });
await page.goto(link);
Enter fullscreen mode Exit fullscreen mode

A link is only picked out when its URL says what it is for (confirm, verify, activate, reset, password, magic, sign-in, login). /magic-link?token=… came back; /t/<token> came back as null — that's what the fifth test in the run checks. Links rewritten by click tracking fall in the same case. Then read the body yourself:

const summary = await sink.waitForMessage(inbox.id, { timeoutMs: 60_000 });
expect(summary).not.toBeNull(); // waitForMessage returns null on timeout, it doesn't throw

const message = await sink.getMessage(summary.id);
const link = message.text.match(/https?:\/\/\S+/)[0];
await page.goto(link);
Enter fullscreen mode Exit fullscreen mode

When the code is not detected

A number only counts as a code when the email announces it: a word like code, verification, OTP, PIN, sign in or login near it or in the subject. That keeps order totals and phone numbers out. Codes are 4 to 8 digits; letters (A7K-92Q) are not supported. If your email says "Here is your number: 482913" and nothing else says code, use waitForMessage and your own pattern.

The full guide, with CI setup and the plain-HTTP version for other languages, is on inboxsink.com. Cypress and Selenium (Python) versions are there too.

Top comments (0)