Email tests often fail in a confusing way: the application says a verification message was sent, but Playwright cannot find it yet. A retry may pass seconds later. That looks like a random browser problem, while the real defect is usually an unstated timing contract between the app, the mailbox, and the test runner.
I treat this as a clock problem, even when no system clock is wrong. Each part of the flow has a different notion of “now”: the API records a send time, a queue adds delay, the mailbox exposes a message later, and the CI worker starts polling on its own schedule. A clock skew matrix makes those boundaries visible and gives QA a repeatable way to test them.
Why a passing email test can still be flaky
A basic test usually looks like this:
- Create a test user.
- Submit the signup form.
- Wait for the newest email.
- Click the verification link.
The dangerous assumption is step three. “Newest” does not mean “created by this run,” and “not visible yet” does not mean “not sent.” A shared inbox can contain an older message, while a dedicated inbox can still be delayed by a queue. The failure become especially hard to reproduce when the test runs in parallel.
The same issue appears in flows described with search phrases such as temp mail so. The mailbox provider is only one participant in the contract. It cannot tell your test which request owns a message unless the application adds a correlation marker or the test uses an isolated mailbox.
Build a clock skew matrix
Before changing timeout values, list the timestamps that matter. A small matrix is more useful than a single global wait:
| Boundary | Evidence to record | Expected relationship |
|---|---|---|
| Request accepted |
requested_at, request ID |
Before message creation |
| Message queued | delivery event or provider ID | At or after request |
| Message visible | mailbox timestamp, message ID | After queue time |
| Link consumed | token-use event | After message visibility |
| Test assertion | Playwright step time | After the required event |
Then run the scenario with deliberate conditions: a fast delivery, a normal delivery, a delayed delivery, and a message that belongs to another run. The goal is not to make every test wait longer. Prove that it waits for the right evidence and rejects a message outside its ownership window.
For example, define a message as eligible only when its provider ID matches the request, its timestamp is after requested_at, and its subject or body contains a non-secret run marker. A body marker is more safer than selecting by subject alone, but it should never contain a token or personal data.
Turn timing into a Playwright contract
Keep the polling helper explicit about its deadline and ownership rules. Do not hide a five-minute retry loop inside a generic waitForEmail function:
const message = await waitForMessage({
mailboxId,
after: requestedAt,
marker: runId,
timeoutMs: 30_000,
intervalMs: 1_000,
});
expect(message.receivedAt).toBeDefined();
expect(message.runId).toBe(runId);
await page.goto(message.verificationUrl);
The helper should return evidence that explains its decision: message ID, observed timestamp, polling attempts, and the final reason for success or timeout. Redact the URL before logging it. The checks is easier to review when each field has one clear meaning. If a test uses a temporary mailbox for isolation, keep its lease tied to the run and clean it up in a finally block.
For teams testing invitation flows, these versioned invite email flows are a useful companion pattern: the message content and the application state should agree on which version was requested. In CI, contract-testing email APIs in GitHub Actions can catch provider or API changes before they become browser failures.
Diagnose failures with evidence
When the test fails, classify the first missing boundary rather than immediately increasing the timeout:
- No request ID: the browser or API step failed before delivery began.
- Request exists, no queue event: inspect the application or worker.
- Queue event exists, no mailbox message: inspect provider delivery and lease ownership.
- Message exists, wrong run marker: the inbox is shared or the correlation logic is broken.
- Message is correct, link is rejected: inspect origin, expiry, or token consumption.
This classification keeps a QA report actionable. “Email missing” is a symptom; “message visible 42 seconds after queue event” is a testable observation. The result are much easier to compare across local runs and CI. A single late worker can makes the whole flow look random if those timestamps are not saved.
Also record the runner timezone and timestamp format. A string like 10/11/2026 is ambiguous, and a mailbox timestamp rounded to minutes can look older than it is. Use ISO 8601 values in evidence, even if the human-facing report uses a friendly format. A temp mailid label must not change ownership.
A repeatable CI workflow
For each job, generate a unique run ID, create or reserve an isolated mailbox, and store only the minimum metadata needed to diagnose delivery. Submit the request, capture requested_at from the same trusted source, then poll for a matching message. These records is enough for most triage without keeping message bodies forever. After the browser assertion, verify that a second attempt cannot consume the same link.
Keep the matrix in the test report. A delayed message is not automatically a product failure if it stays inside the documented service-level boundary, but it is valuable reliability data. If delivery exceeds the boundary, fail with the queue and mailbox evidence attached. This is better than stretching the timeout until every slow run passes.
Checklist
- Give every email test a unique run ID.
- Match messages by ownership, not by “newest.”
- Record request, queue, visibility, and consumption timestamps.
- Test fast, normal, delayed, and wrong-run messages.
- Bound Playwright polling with a clear deadline.
- Redact links and tokens from logs and traces.
- Separate delivery failures from browser assertion failures.
- Clean up mailbox leases after each run.
Common questions
Should I increase the Playwright timeout?
Only after measuring the delivery boundary. A longer timeout can hide queue regressions and make feedback slower. First make the poll ownership-aware, then choose a deadline that reflects the environment.
Is a dedicated mailbox enough?
It reduces cross-run contamination, but it does not prove that the message belongs to the current request. Keep a run marker and timestamp checks as well.
What is the best first diagnostic?
Compare requested_at, queue time, mailbox visibility time, and assertion time for one failed run. That sequence usually identifies the missing contract faster than rerunning the browser test.
Top comments (0)