A Playwright Inbox Probe for Slow Email Tests
Email-driven browser tests often fail in the least helpful way: the registration form passes, but the test cannot find the verification message. The failure usually look random because the browser, the application, and the mailbox are moving on different clocks.
A reliable test needs more than a long timeout. It needs an inbox probe: a small, bounded workflow that proves which mailbox belongs to the test, polls for the expected message, and records enough evidence to explain a miss.
This pattern works with a disposable email generator or a free temp email service, but the important idea is isolation. The email address is a test capability for one run, not a shared identity for the whole suite.
Why slow inbox tests look random
Consider a sign-up test with these steps:
- Create an address.
- Submit the sign-up form.
- Wait for the verification email.
- Open the link.
There are several independent delays between steps 2 and 3. The application may enqueue the message, a worker may deliver it, and the inbox API may expose it a little later. If the poll starts too early, it can pass in one run and time out in the next one.
The common workaround is waitForTimeout(5000). That hides the timing model rather than testing it. Five seconds may be wasteful on a fast run and too short during a busy CI run. A better test waits for a condition with a deadline and reports the observations along the way.
Define the inbox probe
Before writing code, define what “the right message” means. A useful probe has four checks:
- Ownership: the message is addressed to the mailbox created by this test.
- Correlation: a run ID, recipient, or unique token connects the message to the current test.
- Readiness: the message has a subject and body that can be parsed.
- Bounded waiting: the probe stops at a known deadline.
Do not match only on a generic subject such as “Verify your email”. Two parallel workers can both see it. Include a unique value in the test address or sign-up request, then use that value in the query. This is the same reasoning used when adding a receipt file for API runs: the result should carry evidence that can be connected to one execution.
The distinction between a test capability and a user identity is useful here. A mailbox can receive one verification message without becoming a long-lived account. For a related view of this boundary, see a capability boundary for verification tokens.
Implement bounded polling in Playwright
Keep the mailbox client behind a small function. The browser test should not know whether the inbox is backed by an API, a local fake, or a disposable service.
import { expect, test } from '@playwright/test';
async function waitForVerification(
mailbox: MailboxClient,
address: string,
runId: string,
deadlineMs = 30_000,
) {
const started = Date.now();
let attempts = 0;
while (Date.now() - started < deadlineMs) {
attempts += 1;
const messages = await mailbox.list({ to: address });
const message = messages.find((item) =>
item.to === address && item.text.includes(runId),
);
if (message) {
return { message, attempts, elapsedMs: Date.now() - started };
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
throw new Error(`Verification email not found for ${address}`);
}
test('verifies a new account', async ({ page }, testInfo) => {
const runId = `${testInfo.workerIndex}-${testInfo.testId}`;
const address = await mailbox.create(`pw-${runId}`);
await page.goto('/signup');
await page.getByLabel('Email').fill(address);
await page.getByRole('button', { name: 'Create account' }).click();
const result = await waitForVerification(mailbox, address, runId);
await page.goto(result.message.verificationUrl);
await expect(page.getByText('Account verified')).toBeVisible();
});
The sample keeps the deadline separate from the polling interval. That makes the behavior visible in code and easy to tune. The small distinction save a lot of debugging time when CI becomes slower.
Your real message client may return HTML instead of text, or may need pagination. Keep those details in MailboxClient; do not spread provider-specific parsing through every test.
Capture evidence when the probe fails
A timeout without context is a poor diagnosis. On failure, record:
- the run ID and test worker;
- the mailbox address, redacted when logs are shared;
- poll count and elapsed time;
- message IDs and received timestamps;
- the last response status from the inbox service;
- a screenshot or trace of the browser state.
Avoid logging full message bodies or verification URLs. They can contain personal data or reusable tokens. Search logs can also contain noisy queries such as fake e mail com or tamp mail com; redact or classify those values instead of treating them as real mailbox addresses.
If the test needs a public disposable address for a controlled check, a facebook temp email can be one input to the mailbox setup. It should still be scoped to the run, and the test should never rely on an old message remaining in that inbox.
A repeatable CI checklist
Before calling an email test reliable, verify these points:
- Every worker gets a unique mailbox or correlation token.
- The message query filters by recipient and run-specific data.
- Polling has both an interval and a hard deadline.
- The failure includes mailbox and delivery evidence.
- Verification links and message bodies are not printed into shared logs.
- Cleanup happens after success and failure, with a bounded cleanup timeout.
- A local fake mailbox covers parser and UI tests, while a real integration job covers delivery behavior.
The cleanup step are still needed even when the verification assertion fails. A try/finally block around mailbox lifecycle code is usually enough, and a cleanup failure should be reported without hiding the original assertion.
An inbox probe does not make email delivery instant. It makes the delay observable, the ownership rules explicit, and the failure reproducible. That is the difference between a flaky Playwright test and a useful signal for the QA team.
Top comments (0)