DEV Community

Cover image for How to test email verification in Playwright without a shared inbox
Asif Khan
Asif Khan

Posted on

How to test email verification in Playwright without a shared inbox

Your signup flow sends a verification code. Your test needs to read it.

This sounds like a five-minute problem and it isn't, because the test needs two things at once: a real inbox that can actually receive mail, and a different address on every run so two runs never read each other's mail.

Here is the test everyone writes first:

test('user can sign up', async ({ page }) => {
  await page.goto('/signup');
  await page.fill('#email', 'qa@example.com');
  await page.click('#submit');

  const code = await getCodeFromMailbox();  // ← the hard part
  await page.fill('#code', code);

  await expect(page.locator('.welcome')).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Everything is easy except line 7.

Why the obvious answers break

A shared team mailbox. Works on your laptop. Then CI runs four workers in parallel, two of them sign up within the same second, and each grabs the newest message — which belongs to the other. You get a test that fails maybe one run in six.

That intermittency is worse than a test that always fails, because people stop believing it. They hit re-run, it passes, and the habit sticks. Eventually a real bug hides behind the same re-run.

Plus-addressing (you+test1@gmail.com). Free, instant, and increasingly rejected. More signup forms strip or refuse the + every year, and when yours does, you are no longer testing your own validation — you are testing Gmail's. If your product deliberately blocks plus-addressing to stop trial abuse, this approach tests the opposite of what you shipped.

IMAP against a real mailbox. This works, and it is what most teams end up with. The costs are: mail credentials in CI, a polling loop you have to write and tune, and last week's messages still sitting in the box waiting to match your regex before the new one arrives. You will write a since filter. You will get it slightly wrong. You will spend an afternoon on it.

A catch-all domain you own. Technically the cleanest. Also means you now operate mail infrastructure in order to test a signup form. Fine for a large team, hard to justify for one test.

What you actually want

An inbox created per run, over HTTP, that disappears afterwards. No mailbox to maintain, no credentials in CI, no collisions, no leftover mail.

That is a small enough job that several services do it — Mailosaur, MailSlurp, Mailsac, and Mailfornet, which I build, so weigh that accordingly. The pattern below is the same whichever you pick; only the method names change.

npm install mailfornet
Enter fullscreen mode Exit fullscreen mode
import { Mailfornet } from 'mailfornet';

const mf = new Mailfornet(process.env.MAILFORNET_API_KEY);

const inbox = await mf.createInbox();
await signUp(inbox.address);
const code = await mf.waitForCode(inbox.address);
Enter fullscreen mode Exit fullscreen mode

Three lines, and none of them is a loop.

The full Playwright test

import { test, expect } from '@playwright/test';
import { Mailfornet } from 'mailfornet';

const mf = new Mailfornet(process.env.MAILFORNET_API_KEY);

test('a new user can verify their email', async ({ page }) => {
  // A fresh address, used by this test and nothing else.
  const inbox = await mf.createInbox({ ttlMinutes: 15 });

  await page.goto('/signup');
  await page.fill('#email', inbox.address);
  await page.fill('#password', 'correct horse battery staple');
  await page.click('#submit');

  await expect(page.locator('.check-your-email')).toBeVisible();

  // Holds the connection open until the mail lands.
  const code = await mf.waitForCode(inbox.address, {
    from: 'noreply@yourapp.com',
    subject: 'verify',
    timeout: 60,
  });

  await page.fill('#code', code);
  await page.click('#verify');

  await expect(page.locator('.welcome')).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

ttlMinutes: 15 means the address deletes itself a quarter of an hour later, whether the test passed, failed, or crashed. There is nothing to clean up in an afterEach.

Four things that will save you an afternoon

1. Filter by sender and subject, always

The moment your app sends more than one email, "the newest message" becomes a coin flip. A welcome email and a verification email racing each other will fail your test roughly half the time, and only in CI, where the timing is different.

const code = await mf.waitForCode(inbox.address, {
  from: 'noreply@yourapp.com',
  subject: 'verify',
});
Enter fullscreen mode Exit fullscreen mode

Both are substring matches, so subject: 'verify' catches "Verify your email" and "Please verify your account".

2. Your test timeout must outlast your mail timeout

If Playwright gives up after 30 seconds while you are waiting 60 for mail, you get a Test timeout of 30000ms exceeded — which tells you nothing about mail. The failure you want to read is "no matching message arrived in 60s".

test.setTimeout(90_000);  // comfortably more than the 60s wait
Enter fullscreen mode Exit fullscreen mode

Make the mail timeout the shorter of the two, always.

3. Wait, don't poll

This is the difference between one HTTP request and sixty:

// Don't
for (let i = 0; i < 60; i++) {
  const messages = await mf.listMessages(inbox.address);
  if (messages.length) break;
  await sleep(1000);
}

// Do
const message = await mf.waitForMessage(inbox.address, { timeout: 60 });
Enter fullscreen mode Exit fullscreen mode

The second version holds the connection open and returns the moment mail arrives — typically in under two seconds, rather than on your next poll tick. It is also cheaper on any metered API, because waiting a minute costs one request instead of sixty.

If you are rolling your own against a different service, check whether it supports long-polling before you write the loop. Most do.

4. Make inbox creation safe to retry

CI retries. If your runner retries a step after a network blip, you do not want two inboxes and a test watching the wrong one.

const inbox = await mf.createInbox({
  idempotencyKey: `${process.env.GITHUB_RUN_ID}-${test.info().title}`,
});
Enter fullscreen mode Exit fullscreen mode

Same key, same inbox. Different key, different inbox.

Testing password reset too

The same pattern covers every transactional email, not just signup. Password reset usually needs a link rather than a code, so take the message and pull the URL out yourself:

const message = await mf.waitForMessage(inbox.address, {
  subject: 'reset',
  timeout: 60,
});

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

message.html is there too, if your email only puts the link in the HTML part.

One thing worth deciding early

Do you want these tests running against production email infrastructure on every pull request?

Most teams want the full path — your app, your ESP, real SMTP — on a nightly or pre-release run, and something faster on every commit. Running the real thing 200 times a day is slow and, on a metered ESP, not free.

A reasonable split:

  • Every commit: unit-test that the mail was queued, with the template and variables you expect. Milliseconds, no network.
  • Nightly or pre-release: the full test above, real mail, real inbox.

That way a broken template is caught within minutes, and a broken delivery path is caught before release — without paying for the second on every push.

Wrapping up

The trick isn't clever code. It's refusing the shared mailbox in the first place.

Once each run has its own address, every awkward part goes away on its own: no collisions, no stale mail to filter past, no credentials in CI, no cleanup step. The test ends up reading almost like the manual steps a person would take, which is usually a sign you picked the right approach.


I build Mailfornet, which is where the code samples come from — it has a free tier of 100 requests a month if you want to try the pattern. The approach works the same with any of the services mentioned above.

What does your team do about this? I'm genuinely curious how many people are still running a shared QA mailbox, because I suspect it's more than admit to it.

Top comments (0)