General
Testing email verification flows in Playwright without a mail server
Louis Terrell DEV Community 周榜
2 views
Most signup tests die at the same line.
The form submits, the app renders "check your email", and the test has nowhere to go. You can assert the confirmation screen appeared, but everything after it — the code entry, the activated account, the first authenticated page — stays untested.
The usual workarounds are worse than the gap:
Someone opens a shared mailbox by hand during a release.
A test reads the code out of a log line, which only works if the app logs it, which it should not do in production.
A staging flag skips verification entirely, so the thing you ship has a path no test ever walked.
This post covers the fourth option: give the test a real inbox it can read over HTTP.
Why not a shared mailbox
A single shared test mailbox works until you run two tests at once. Both sign up, both poll the same inbox, and each can read the other's code.
You end up sorting by recipient or subject, which is guesswork dressed as logic, and the failures are intermittent and hard to reproduce. The worst kind.
This is worth dwelling on because it is usually misdiagnosed. "Wrong OTP getting picked" reads as a timing problem, so people add retries and longer waits, and the flake stays. It is not timing. It is two tests reading index 0 of the same array.
Why not a self-hosted mail server
Mailhog and Mailpit both work, and if you are already running one, this post is probably not for you.
The cost is that it is one more service to operate — DNS, TLS, storage, and a container that has to be healthy before any test starts. That is fine at a certain size and heavy if all you want is to read a six-digit number.
The other thing worth knowing: a local SMTP sink tests that your app handed a message to localhost. It does not test that mail leaves your infrastructure. If the failure you need to catch is a provider or DNS problem, you still want one test against real delivery.
The approach
One disposable address per test, read over HTTP.
Addresses are cheap enough to create one per test run, so nothing is shared between concurrent tests. The trade-off is real and worth stating: your suite now depends on an external service, and mail from your app has to actually reach the public internet. If your staging environment cannot send outbound mail, none of this helps.
I use my own service for this below, but the pattern works with any disposable inbox that exposes an API. Mailosaur and Mailsac both do, and Mailosaur in particular documents its behaviour well.
The helper
No push mechanism exists for this, so polling is the only option. The important part is a deadline rather than a fixed sleep:
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function waitForMessage(
email: string,
options: {
timeoutMs?: number;
intervalMs?: number;
match?: (m: MessageSummary) => boolean;
} = {},
): Promise<Message> {
const { timeoutMs = 60_000, intervalMs = 2_000, match = () => true } = options;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const summaries = await listInbox(email);
const hit = summaries.find(match);
if (hit) return readMessage(hit.id);
await sleep(intervalMs);
}
throw new Error(`No matching message for ${email} within ${timeoutMs}ms`);
}
Two details. The match predicate runs against message summaries, so it can only use summary fields — matching on subject or fromAddr is usually enough to skip an unrelated welcome email. And it returns the full message, because the summary you matched has no body and you almost always want one.
Getting the code out
export function extractCode(body: string | null, digits = 6): string {
if (!body) throw new Error("Message has no text body");
const match = body.match(new RegExp(`\\b\\d{${digits}}\\b`));
if (!match) throw new Error(`No ${digits}-digit code found in:\n${body}`);
return match[0];
}
Be honest about what this regex is: a guess about your own email template. It finds the first standalone run of six digits. If your footer contains a year range, an order number, or a phone number without separators, it can match the wrong thing.
Anchor it to something stable when you can — /code is (\d{6})/, or a line with a known label. When the template changes this is the line that breaks, and printing the body in the error means you see why in one run instead of three.
The retention trap
This is the part that is not in most write-ups, and it cost me an afternoon.
Disposable inbox services delete mail on a schedule, and the schedule is not always what you would assume. Many run a fixed countdown from when the address was created, not from when the last message arrived.
So a provider that works fine when your OTP arrives in 30 seconds will quietly fail when the same email takes two minutes under CI load. The message arrives, gets deleted, and your poll finds an empty inbox. It looks exactly like a flaky wait, and you will spend your time tuning timeouts instead of looking at the retention policy.
Worse, most services do not publish the number. I checked twelve of them and only four documented a retention figure at all. For the rest you send a test message and time it with a stopwatch.
So before you pick a provider: find out how long it keeps mail, and whether the window is fixed or slides on each new message. If the service will not tell you, that is itself an answer.
What this does not solve
If you control the backend, a test-only endpoint that returns the current OTP is more deterministic than any of this, and several people will tell you so. They are right. It also stops testing the email path, so keep one test that goes through real delivery.
Magic links work the same way — extract the URL instead of the digits — but you need to handle the link opening in the same browser context.
This adds a network dependency to CI. That is a real cost and you should weigh it.
The full walkthrough with the complete helper module, the end-to-end test, and the CI setup is on my site: Playwright Email Testing: Automating Verification Codes
Disclosure: the API in the examples is Spare Mailbox, which I built. The pattern is portable — swap the three calls for any provider with an inbox API.
Read original: https://dev.to/louis_terrell_dev/testing-email-verification-flows-in-playwright-without-a-mail-server-29bn
← Previous
How Staking and Rewards Work on the TRON Blockchain
Next →
Your primary key shouldn't be in the URL
Related
TACACS+ Failover Testing: Rejection, Outage, and Recovery Are Different Tests
General
3
DEV Community 周榜
Caesar Cipher Explained: How It Works, Encryption, Decryption, and Examples
General
2
DEV Community 周榜
Why I Built My Portfolio with Bun + Astro + MDX Instead of a More Complex Stack
General
3
DEV Community 周榜
Your primary key shouldn't be in the URL
General
3
DEV Community 周榜
Comments0
No comments yet — be the first