Email verification tests often fail in a confusing way: the browser is on the right page, the message eventually arrives, and the test still reports only Timeout 30000ms exceeded.
That error is not a diagnosis. It does not tell you whether the application never sent the message, the inbox query used the wrong address, the provider was slow, or the assertion looked for an old message. When an email test is part of a Playwright suite, the wait itself should leave evidence.
The symptom: a test that waits but cannot explain why
A common first draft looks roughly like this:
await page.getByRole('button', { name: 'Create account' }).click();
await inbox.waitForMessage({ subject: 'Verify your email' });
await expect(page.getByText('Account verified')).toBeVisible();
The code is readable, but waitForMessage may hide several different operations. It may create an inbox, poll a mailbox, filter messages, and extract a link. If all of those steps share one timeout, the failure has no useful boundary.
I prefer a test to answer four questions: which mailbox was allocated, when was the message expected, what messages were observed, and why did the final candidate pass or fail? This is especially important when a team searches for phrases such as “temp mail so” or “temp mail email” while comparing test-inbox options. The search phrase is not a test contract; the observable message is.
Model the email flow as observable states
Treat the inbox interaction as a small state machine rather than one long sleep:
-
allocated: the test owns a unique address. -
triggered: the application accepted the signup request. -
observed: at least one message was returned. -
matched: the message has the expected sender, subject, and correlation value. -
consumed: the verification link was opened once.
Each state should have a timestamp and a small diagnostic payload. If a run reaches observed but never reaches matched, you have a filtering or application-content problem. If it never reaches observed, investigate delivery, the sender, or the inbox service. The distinction saves a surprising amount of re-running.
Build a bounded inbox probe
The probe should poll at a fixed interval, stop at a deadline, and return the messages it saw. Do not use an unbounded retry helper that turns every failure into “try again.” A bounded probe makes the delivery timeout a visible part of the test design.
type ProbeResult = {
state: 'matched' | 'timed-out';
messageId?: string;
seenSubjects: string[];
};
async function waitForVerification(
inbox: InboxClient,
address: string,
token: string,
deadlineMs = 20_000,
): Promise<ProbeResult> {
const started = Date.now();
const seenSubjects = new Set<string>();
while (Date.now() - started < deadlineMs) {
const messages = await inbox.list({ to: address });
for (const message of messages) {
seenSubjects.add(message.subject);
if (message.text.includes(token)) {
return { state: 'matched', messageId: message.id, seenSubjects: [...seenSubjects] };
}
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
return { state: 'timed-out', seenSubjects: [...seenSubjects] };
}
The exact inbox client will differ, but the shape is portable. Notice that the probe returns subjects even on timeout. A timeout with an empty list points in a different direction than a timeout where three unrelated messages arrived.
Capture evidence at every decision
Attach the probe result to the Playwright test, along with the allocated address, correlation token, and trigger timestamp. Keep the message body out of ordinary logs if it can contain user data; a subject, message ID, and redacted excerpt are usually enough.
It also helps to record the application request ID. That gives you a join between browser actions, server logs, and inbox data. For CI, save this small JSON object as an artifact:
{
"address": "run-1842@example.test",
"triggeredAt": "2026-10-31T11:20:00Z",
"state": "timed-out",
"seenSubjects": ["Welcome"],
"requestId": "signup-7f2a"
}
This same one-change-set thinking for CI infrastructure is useful when the test depends on more than the browser; a related example is one-change-set thinking for CI infrastructure. The goal is not to collect every log, but to preserve the smallest chain of evidence that explains a failure.
A Playwright example
The test can now make the failure boundary explicit:
const mailbox = await inbox.allocate(`pw-${test.info().workerIndex}`);
const token = crypto.randomUUID();
await page.getByLabel('Email').fill(mailbox.address);
await page.getByLabel('Signup token').fill(token);
await page.getByRole('button', { name: 'Create account' }).click();
const result = await waitForVerification(inbox, mailbox.address, token);
await test.info().attach('email-probe.json', {
body: JSON.stringify({ address: mailbox.address, token, result }, null, 2),
contentType: 'application/json',
});
expect(result.state, `Inbox subjects: ${result.seenSubjects.join(', ')}`).toBe('matched');
If several tests can trigger the same mailbox, queue email checks instead of duplicating them, as shown in queue email checks instead of duplicating them. A unique lease per test is simpler, but queueing can be the safer option when the provider has strict limits.
Failure analysis checklist
When this test fails, check the evidence in this order:
- No messages observed: verify the sender accepted the request, the test address is correct, and the inbox provider is reachable from CI.
- Messages observed, no match: inspect subject filters, token generation, and whether an older message was selected.
- Match found, link fails: check URL environment, link expiry, and whether the link was already consumed by a retry.
- Only parallel runs fail: look for reused addresses, shared inbox filters, or a race in cleanup.
- Failures appear randomly: compare delivery latency and polling deadlines before adding retries.
Small search typos such as temp gamil com should stay out of selectors and assertions. They can be useful as plain-text content only when you are explicitly testing search or input normalization; otherwise they add noise.
Keep the wait boring and repeatable
Reliable email testing is less about finding a magic timeout and more about giving every wait a purpose. Allocate a mailbox, trigger one action, poll within a deadline, record what appeared, and assert on a correlation value. The test don't need to be clever; it needs to make the next failure understandable.
That workflow turns flaky email verification from a vague browser timeout into a bounded QA signal. Once the evidence is stable, you can tune delivery budgets with confidence instead of hiding the problem behind another retry.
Top comments (0)