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)
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);
});
-
createInboxreturns an address nobody else is using. WithttlSeconds: 900the inbox deletes itself after 15 minutes, so a crashed test leaves nothing behind for long. -
waitForOtpkeeps 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,
waitForOtpthrows 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
});
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);
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);
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)