Email verification tests often fail in the inbox step, but the inbox is rarely the real cause. A signup test can pass the form, create a user, request a message, and then fail because the assertion ran before the message became visible. The failure is usualy reported as “email not found,” which hides the more useful question: what was the first state that became impossible?
This Playwright workflow works with a temp mail so provider, a controlled burner email service, or an internal test inbox. Give every transition a timestamp, an owner, and a clear failure message.
Why email tests fail after the signup step
An email test usually crosses several systems:
- The browser submits the registration form.
- The application commits the account and queues an email.
- A mail service accepts and processes the message.
- The inbox API exposes the message.
- Playwright opens the message and verifies the link or code.
Each step has a different kind of delay. A browser assertion can wait for a DOM element, but it cannot automatically wait for a message that has not been delivered yet. A fixed waitForTimeout sometimes masks this timing gap, then fails again under CI load.
The inbox is not a database query; its a delayed event. Treating it like one creates misleading retries and slow debugging.
Model the test as a state timeline
Before changing timeouts, name the states your test expects. For example:
form_submitted
account_created
message_requested
message_received
verification_link_opened
account_verified
Record the transition that your test can observe. The browser can observe the submitted form and the verification page. Your inbox client can observe message receipt. Your application logs can confirm account creation. If the test only records the final assertion, all earlier failures collapse into one vague symptom.
A small event record is enough:
type EmailTestEvent = {
state: string;
at: string;
details?: Record<string, string>;
};
const events: EmailTestEvent[] = [];
function mark(state: string, details?: Record<string, string>) {
events.push({ state, at: new Date().toISOString(), details });
}
Call mark("form_submitted") immediately after submit, and mark the inbox state only after the inbox client confirms a matching message. When a test fails, print the timeline with the test name and run ID.
A Playwright diagnostic workflow
Use a unique address per test or per worker. A shared burner email makes parallel tests compete for the same messages, so one test can consume another test’s verification link. Unique identity also makes cleanup and investigation safer.
const testEmail = `pw-${testInfo.workerIndex}-${Date.now()}@example.test`;
mark("test_identity_created", { email: testEmail });
await page.getByLabel("Email").fill(testEmail);
await page.getByRole("button", { name: "Create account" }).click();
mark("form_submitted");
await expect(page.getByText("Check your email")).toBeVisible();
mark("account_created");
const message = await inbox.waitForMessage({
to: testEmail,
subject: "Verify your account",
timeoutMs: 30_000,
});
mark("message_received", { messageId: message.id });
const link = message.links.find((item) => item.includes("/verify/"));
expect(link).toBeTruthy();
await page.goto(link!);
mark("verification_link_opened");
await expect(page.getByRole("heading", { name: "Account verified" })).toBeVisible();
mark("account_verified");
The contract around waitForMessage should filter by recipient and subject, return a message ID, and fail with observed candidates. “Timed out after 30 seconds” is less useful than “received two messages for another recipient; no matching subject for this run.”
If your inbox API supports it, pass a run-scoped token in the message metadata. A simple contract can prevent an old message from satisfying a new test. The idea is similar to keeping email policy snapshots: preserve the evidence that explains what the system decided at that moment.
Keep inbox reads isolated and observable
There are three boundaries worth enforcing:
- Identity boundary: generate an address that no other test uses.
- Message boundary: match the recipient, subject, and run marker.
- Cleanup boundary: expire or delete the inbox after the test, while retaining a small failure record.
Do not hide the inbox call inside a generic retry that catches every error. A retry should handle a message that is not available yet, not authentication errors, malformed responses, or a message with the wrong recipient. Those are different defects and should fail immediately.
For agent-driven or command-line test systems, a documented CLI inbox contract is helpful for the same reason. Define what “waiting” returns, how long it waits, and which evidence is retained. Otherwise, every caller invents a slightly different polling loop.
Also check the clock used in logs. Browser, application, and inbox-provider time may not line up perfectly. Use one local sequence number so the report can show event order. The report become less confusing when timestamps are supplementary evidence.
A practical failure checklist
When the test fails, inspect these items in order:
- Was the address unique? Shared inboxes create false positives and missing messages.
- Did the UI show the post-submit state? If not, investigate the form or API response first.
- Was a message requested? Check the application event or request log.
- Did the inbox receive any message? Keep the candidate subjects and recipients in the failure output.
- Did the message belong to this run? Reject stale messages and links from previous attempts.
- Was the link extracted correctly? Verify the URL pattern before navigating.
- Did the verification page fail? Preserve the browser trace and the final response details.
The checklist is intentionally boring. Boring checks are repeatable, and repeatability is what turns flaky Testing into a diagnosable workflow. It also keeps a typo such as tepm mail com from becoming a hidden fixture assumption in a test report.
Questions teams usually ask
Should I increase the timeout first?
Only after confirming that the message is eventually delivered and belongs to the correct test. A longer timeout can reduce noise, but it cannot fix a missing event, a bad filter, or a shared inbox race.
Is polling always a problem?
No. Polling is reasonable when the provider offers no webhook or event stream. Make it bounded, observable, and selective. Record each retry reason, but keep the final failure short enough to scan in CI.
What should a retry repeat?
Prefer retrying the inbox read, not the entire signup flow. Repeating signup may create duplicate accounts and multiple verification messages, which make the original failure harder to understand.
What does a good failure message look like?
It names the run, recipient, expected subject, elapsed time, last observed state, and a safe list of candidate messages. That gives the next engineer a starting point instead of another unexplained timeout.
Once the test reports its first bad state, email verification stops being a mysterious end-to-end failure. Playwright remains responsible for browser behavior, the inbox client remains responsible for delivery evidence, and the timeline connects both without pretending they are one system.
Top comments (0)