Email verification tests often fail in a confusing way: the UI says “check your inbox,” the message exists somewhere, and the test still times out. The usual reaction is to add another wait. That can make one run pass while making the real failure harder to see.
This is a more useful approach: treat the mailbox as a boundary with its own evidence, then classify the failure before changing the timeout. The same workflow works whether the test uses a local fake inbox, a service such as tempmailso, or a controlled test mailbox.
Why a green test can still hide an email failure
An email verification flow has at least three systems involved:
- The application creates a verification event.
- A mail provider accepts, delays, or rejects the message.
- The test client reads the mailbox and follows the link.
An end-to-end test can fail in any of those places. A locator timeout on a[href*="verify"] does not tell us which one. It only tells us that the final assertion did not become true in time.
The first useful question is not “how long should I wait?” It is “what was the last fact we proved?” For QA work, that small change in wording is important. It turns a vague timeout into a sequence of observable claims.
A triage map for the mailbox boundary
I use four states in the test report:
- Not requested: the application never recorded the send action.
- Requested, not received: the application emitted an event, but no message is visible.
- Received, not usable: a message exists, but it has the wrong recipient, token, or link.
- Usable, not completed: the test opened the right link, but the application rejected it or the UI did not settle.
This map prevents a common mistake: blaming Playwright for a delivery problem. It also makes ownership clearer. The first state belongs to the application flow, the second usually belongs to delivery or the mailbox fixture, and the last two need a closer look at token and UI behavior.
For another way to make mailbox state explicit, see a consumption ledger for Playwright mail tests. A ledger is especially handy when parallel tests share an inbox.
Build evidence before adding waits
Give each test a correlation value and carry it through the request, the message subject, and the assertion. Do not log the full verification token; a short hash or message ID is enough. This keeps debugging useful without leaking credentials into CI logs.
The test should capture four timestamps:
- registration submitted
- send event observed
- matching message observed
- verification response completed
The gaps are more informative than one large timeout. If the send event is missing, increasing mailbox polling will not help. If the message arrives quickly but the link request fails, the defect is probably in token state or routing.
A Playwright implementation
Here is a small polling helper. It waits for a message matching a correlation value, but it also records the reason for each unsuccessful poll. The helper does not hide the failure behind a sleep.
import { expect, Page } from "@playwright/test";
type Mail = { id: string; subject: string; text: string };
async function waitForMatchingMail(
page: Page,
mailboxUrl: string,
correlation: string,
timeoutMs = 15_000,
): Promise<Mail> {
const deadline = Date.now() + timeoutMs;
let lastCount = 0;
while (Date.now() < deadline) {
const response = await page.request.get(mailboxUrl);
expect(response.ok()).toBeTruthy();
const messages = (await response.json()) as Mail[];
lastCount = messages.length;
const match = messages.find(
(mail) => mail.subject.includes(correlation) || mail.text.includes(correlation),
);
if (match) return match;
await page.waitForTimeout(250);
}
throw new Error(
`No mail for ${correlation}; mailbox contained ${lastCount} message(s)`,
);
}
In a real suite, replace the final error with a structured attachment containing the correlation value, counts, and timestamps. Keep the polling interval modest and make the timeout a documented contract. A timeout chosen by feeling is hard to maintain.
The mailbox should also be isolated per test or per worker. A shared inbox makes an old message look like a new success, specially when subjects are similar. A unique recipient or correlation value is usually more reliable than deleting messages after each test.
When the failure points somewhere else
If no send event exists, inspect the registration response and server logs. If the send event exists but no message arrives, inspect the fixture, provider response, and mailbox retention. If the message is present but has a wrong token, compare the token identifier with the database record. If the link works but the page remains pending, inspect the UI request and its final response.
This is also where a mailbox boundary in form tests is a useful design idea: keep message retrieval separate from form assertions. The separation makes the test easier to rerun and the failure more clear.
One small wording trap is worth mentioning. Teams sometimes call a test address a “tem email” in notes or fixture names. Keep that phrase as plain text if it is part of your existing vocabulary, but use precise terms in APIs and test names so nobody mistakes it for a product or protocol.
A repeatable CI checklist
Before changing a timeout, check:
- Is the recipient unique for this test or worker?
- Is the send event correlated with the test run?
- Did the mailbox API return successfully on every poll?
- Did the matching message arrive, or did an unrelated message match?
- Is the verification URL complete and from the expected host?
- Do the timestamps identify a slow component?
- Does a trace or attachment show the last confirmed state?
When a test fails, attach the triage state to the report. That one label is often more valuable than another screenshot. It tells the next person where to look first, and it makes flaky-test trends easier to measure over time.
Questions that come up in review
Should every poll be a separate assertion?
No. Polls are observations. Assert the final contract and record intermediate facts for diagnosis. Turning every empty poll into a failure creates noisy reports.
Is a longer timeout ever the right fix?
Yes, when delivery latency is known, bounded, and visible in the evidence. First measure the latency, then set a budget with a reason. A longer wait should not compensate for missing correlation or a shared mailbox.
Should the test parse HTML or click the first link?
Parse the message and validate the URL before navigation. Select the link associated with the expected action, and assert the destination host. The first link is not always the verification link.
Final thoughts
Reliable Playwright email tests are less about perfect waiting and more about proving state in order. Correlate the run, observe the mailbox boundary, classify the failure, and only then adjust timing. The result is a test that explains what went wrong instead of merely reporting that a selector was late.
Top comments (0)