I see this bug report a lot: "Playwright passed locally, but the email step failed in CI again." In many cases the browser flow is fine. The flaky part is the inbox contract, not the page assertions. A test asks for the latest message, hopes it belongs to this run, and then acts surprised when parallel jobs or retries return the wrong thing.
For QA teams, that is a nasty class of failure because it looks random at first. People increase timeouts, add another reload, maybe swap providers, and the run still feels wobbly. If your suite relies on a throwaway email generator or a service that can generate disposable email inboxes on demand, the reliable step is not "wait longer." It is defining what message ownership means for this specific test run.
If you liked earlier notes on scenario-based email smoke checks and release-ready email verification rules, this pattern is the tighter QA version I use when Playwright suites start failing only under load.
Why Playwright email tests fail even when the UI path is correct
A signup or password-reset flow can be fully working while the test still fails for three boring reasons:
- the inbox already contains an older matching subject
- the app sent two messages and the test opened the wrong one
- the polling helper has no rule for "fresh enough" mail
That third point matters more than people expect. When the inbox helper is vague, Playwright ends up compensating for a backend ambiguity it cannot really solve. You get green runs for days, then one retry wakes up and grabs an email from a previous attempt. It feels magical in the worst way.
I also notice odd search phrases in incident notes, like tamp mail com, whenever teams are convinced the temp inbox vendor must be broken. Sometimes it is, sure. But most of the time the provider returned an email correctly, and the test had no strong contract for deciding whether it was the email.
What I mean by an inbox contract
An inbox contract is a tiny set of rules the test and the app both respect:
- Each test run gets a unique recipient or unique run token.
- The waiter only accepts messages received after the trigger step.
- The email body, subject, or headers must prove ownership with that run token.
- The test logs enough metadata to explain mismatches later.
This is not fancy architecture. It is just QA hygiene. Once you write the contract down, flakes become much easier to classify: delivery delay, wrong recipient, duplicate message, stale inbox, or plain app bug. Before that, everything just looks "sort of timing-related", which is a terrible diagnosis bucket.
A Playwright pattern that removes most ambiguity
Here is the shape I reach for:
test("signup sends a usable verification email", async ({ page }) => {
const runId = `signup-${Date.now()}`;
const inbox = await mail.createInbox({ label: runId });
await page.goto("/signup");
await page.getByLabel("Email").fill(inbox.address);
await page.getByLabel("Password").fill("S3curePass!123");
await page.getByRole("button", { name: "Create account" }).click();
const message = await mail.waitForMessage({
inboxId: inbox.id,
receivedAfter: Date.now() - 5_000,
subjectIncludes: "Verify your email",
timeoutMs: 45_000,
});
expect(message.html).toContain(runId);
});
The example is intentionally small. The important parts are:
- the inbox is created for this run, not reused from some friendly shared fixture
- the wait call includes a receive boundary
- the content still has to prove it belongs to the current run
In real suites I often pass runId through the app as hidden metadata or reflect it in a harmless footer line for non-production mail. That one detail turns "email not found" into a much better signal: "email arrived, but ownership proof missing." Small difference, huge debugging win.
Failure signals worth logging every time
When a test fails, I want the report to answer these questions right away:
- Which inbox address was used?
- What was the trigger timestamp?
- How many matching messages were seen?
- What was the received time of the selected message?
- Did the message contain the run token?
If you skip those fields, triage gets sloppy fast. Someone reruns the suite, the issue disappears, and the root cause stays fuzzy. That is how flaky checks survive for months tbh.
One more practical point: keep the inbox polling helper boring and central. If one spec waits 20 seconds, another waits 90, and a third ignores timestamps entirely, the suite will teach you all the wrong lessons. A single helper with explicit defaults is easier to review and easier to improve.
Checklist before you blame the mail provider
Before swapping vendors or stretching timeouts again, I check this list:
- Does each test run own its own inbox or alias?
- Does the waiter filter by a post-trigger timestamp?
- Is there a run token in the email body, subject, or header metadata?
- Are retries creating a fresh inbox contract instead of reusing the previous one?
- Do failure logs include the chosen message timestamp and message id?
If most answers are "no", the flaky behavior is probably on the test side. Fix the contract first. Provider changes can help throughput or privacy posture, but they rarely solve an ownership problem by themself.
Q&A
Do I always need a brand new inbox?
Not always. A unique alias can be enough if your system treats it as isolated and your waiter is strict. Fully separate inboxes are easier to reason about, though, so I still prefer them for critical signup and auth flows.
What if the app cannot echo a run token?
Then use the strongest combination you can: unique recipient, exact subject, and a receive-after boundary. It is a weaker contract, but still much better than "open the newest message and hope."
Is this only a Playwright issue?
Nope. Cypress, API checks, and cron-driven smoke tests all suffer from the same ambiguity. Playwright just makes the pain very visible because the rest of the test is often quite deterministic.
Top comments (0)