An email test that says only expected verification link, got timeout is not very useful. It tells the QA engineer where the test stopped, but not what happened before that point.
For browser flows that depend on email, I treat the test as a small distributed system. The application creates a message, a delivery service accepts it, an inbox exposes it, and Playwright eventually reads it. Each step has a different failure mode. A causal timeline makes those steps visible, so a slow inbox does not look like a broken signup flow.
This approach works especially well when testing magic links, password resets, and first-login journeys in CI. It also makes a temp mail so workflow easier to review because the test records what it knew at each stage.
Why a pass or fail is not enough
Suppose a user submits a signup form at 10:02:14. The test polls an inbox until 10:02:44, then fails. There are at least four plausible causes:
- The application never queued the email.
- The provider accepted it, but delivery was delayed.
- The inbox returned a message from an older run.
- The message arrived, but its link points to the wrong environment.
Those causes need different owners and fixes. A single timeout hides the distinction are and encourages a retry. Retries can make the next run pass while the original defect stays in place.
Record an event for every boundary: form submitted, message ID observed, message became visible, link extracted, and link opened. Include the run ID, test name, and elapsed milliseconds. The timeline does not need a large observability platform; a JSON attachment in the CI job is already a big improvement.
Build the timeline around stable events
Use events that can be checked independently instead of logging vague sentences such as “waiting for email.” A useful event shape looks like this:
{
"event": "inbox_message_visible",
"run_id": "pw-1842",
"message_id": "msg-73b1",
"elapsed_ms": 8420,
"received_at": "2026-11-08T05:12:18Z"
}
The exact fields depend on the inbox API, but three properties matter. The event must be tied to one run, it must have a monotonic elapsed time, and it must contain enough detail to reproduce the next query. A temp mailid in a log is not a substitute for a real message ID.
Generate the address or inbox identifier once per test. Never let a retry silently reuse an uncertain inbox. Run-scoped identities are easier to clean up, and run IDs for flaky notification jobs explains the same principle from a broader automation perspective.
There is also a useful ownership boundary here. If an email verification worker leases work from a queue, its lease and the browser test's waiting window should be compatible. The details in leases for email verification workers help explain why an inbox can be empty even when the application did create an event.
A Playwright implementation
Keep the polling helper responsible for evidence, not just for waiting. It should return the message and its timeline, or throw an error that includes the timeline collected so far.
type MailEvent = {
event: string;
elapsedMs: number;
details?: Record<string, string>;
};
async function waitForVerificationEmail(
page: Page,
inboxId: string,
runId: string,
): Promise<{ link: string; timeline: MailEvent[] }> {
const started = performance.now();
const timeline: MailEvent[] = [];
for (let attempt = 1; attempt <= 12; attempt++) {
const messages = await readInbox(inboxId);
timeline.push({
event: "inbox_polled",
elapsedMs: Math.round(performance.now() - started),
details: { attempt: String(attempt), count: String(messages.length) },
});
const message = messages.find((item) => item.runId === runId);
if (message) {
timeline.push({
event: "inbox_message_visible",
elapsedMs: Math.round(performance.now() - started),
details: { messageId: message.id },
});
return { link: extractVerificationLink(message.body), timeline };
}
await page.waitForTimeout(1000);
}
throw new Error(`Verification email timeout: ${JSON.stringify(timeline)}`);
}
In real code, add an assertion that the extracted URL has the expected host and a one-time token shape. A message arriving is not the same as a valid message. The failure output should show whether extraction failed, navigation failed, or the final page assertion failed. This can sounds verbose, but it prevents a long debugging session later.
Diagnose failures from the evidence
When the test fails, read the timeline from left to right:
-
No
form_submitted: the browser flow failed before email was relevant. -
form_submittedbut no provider event: inspect the application queue or mail sender. - Provider event but no inbox message: inspect delivery latency, filtering, or the inbox API.
- Message visible with an old run ID: isolate test data and check cleanup.
- Correct message with a bad URL: inspect environment configuration and link generation.
- Correct URL but a failed page assertion: debug the application state, not delivery.
This is where a small amount of imperfect wording in logs can be harmless, but missing causal evidence is not. In one review, we was treating every empty poll as an app failure; separating “not visible yet” from “never queued” made the defect a bit more clearer.
If the timeline shows a normal delivery delay, tune the waiting budget based on observed behavior and keep the test diagnostic. Do not simply increase the timeout until the check pass. For short-lived test inboxes, a service such as tempmailso can be one controlled input, but it should not replace run isolation, URL validation, or evidence collection.
A repeatable QA checklist
Before merging an email-dependent Playwright test, check that:
- The inbox identity includes a unique run or test ID.
- Each boundary emits an event with elapsed time.
- Polling filters by the current run, not just by subject.
- The artifact includes the timeline on failure.
- The verification URL is checked before navigation.
- Delivery delay, stale data, and application errors have distinct messages.
- Cleanup runs after both success and failure.
The goal is not to make email tests noisier. It is to make their failures specific enough that the next person can act on them. A timeline turns “email timeout” into a short investigation with a beginning, a boundary, and a likely owner.
Top comments (0)