DEV Community

Silviu Technology
Silviu Technology

Posted on

Playwright Email Tests Need Deterministic Polling

Email verification tests often look simple in a test plan: submit a form, open the inbox, click the link, and assert that the account is active. In a real CI run, the difficult part is not the click. It is deciding when the message should exist, which message belongs to this browser, and what evidence to keep when delivery is late.

I have found that Playwright email tests become much calmer when the inbox is treated as an external system with an explicit contract. A bounded polling loop, a unique test identity, and a small failure receipt remove most of the guesswork. This approach works with a temporary disposable mail provider or with an internal test mailbox, as long as the interface can filter messages by recipient or correlation value.

Why email assertions become flaky

The most common anti-pattern is a fixed sleep:

await page.waitForTimeout(5000);
await expect(inbox.getByText("Verify your email")).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

Five seconds is too short when a queue is busy and too long when delivery is instant. Worse, the wait hides the reason for a failure. Did the application never enqueue the message? Did the test read a message from another run? Did the link expire before the browser used it?

An inbox is eventually consistent. The test should say how long it is willing to wait and what it will check at each attempt. That makes a timeout meaningful instead of just annoying. It also gives the QA report a repeatable timeline.

Give every message an ownership contract

Parallel workers need separate ownership. At minimum, create a recipient or alias that includes the worker index and a short run identifier. Also put a non-secret correlation ID in the request or message metadata. Never use a shared inbox and assume the newest message is yours; that assumption fails realy quickly when retries overlap.

The assertion contract can be small:

  • the recipient matches the current worker;
  • the subject has the expected intent;
  • the message was created after the test started;
  • the verification link points to the intended environment;
  • the message ID has not already been consumed by another test.

The link itself deserves a separate security check. For flows with invitations or recovery actions, reading about tenant-bound magic links is useful because ownership is part of correctness, not just a backend detail. Likewise, state-bound reset email tokens show why a message can arrive successfully and still be unsafe to accept.

A deterministic Playwright polling helper

Keep the mail transport behind a small interface. The browser test then tests the user journey while the inbox adapter handles provider-specific details.

type Mail = {
  id: string;
  subject: string;
  text: string;
  receivedAt: string;
};

async function waitForMail(
  fetchMail: () => Promise<Mail[]>,
  matches: (mail: Mail) => boolean,
  timeoutMs = 30_000,
  intervalMs = 1_000,
): Promise<Mail> {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const messages = await fetchMail();
    const match = messages.find(matches);
    if (match) return match;
    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }

  throw new Error(`Expected email was not received within ${timeoutMs}ms`);
}
Enter fullscreen mode Exit fullscreen mode

The important choices are the deadline and the predicate. In the real adapter, fetchMail should request only the current recipient when possible, sort by provider timestamp, and redact message bodies from logs. The predicate should match the correlation ID or a unique verification address, not merely a familiar subject.

Then the Playwright test can remain readable:

const startedAt = new Date().toISOString();
const address = await fixtures.createAddress({ worker: test.info().parallelIndex });

await page.getByLabel("Email").fill(address);
await page.getByRole("button", { name: "Create account" }).click();

const mail = await waitForMail(
  () => fixtures.listMessages(address),
  (item) => item.subject.includes("Verify") && item.receivedAt >= startedAt,
);

await page.goto(fixtures.extractVerificationUrl(mail.text));
await expect(page.getByRole("status")).toHaveText("Email verified");
Enter fullscreen mode Exit fullscreen mode

There is a small race here if timestamps have low precision, so a production fixture should prefer a run token or message metadata over time alone. Time is a useful secondary guard, not ownership by itself.

Diagnose failures with small receipts

When a poll times out, save structured evidence for the test run. A receipt can contain the run ID, recipient hash, attempt count, last provider status, and the application request ID. It should not contain a live verification token or the whole email body.

This is where a dedicated temp mail so workflow can be practical for isolated QA fixtures: the test can use a short-lived address without mixing it with a developer's personal inbox. The same rule applies regardless of provider: expire the address, scope access to the run, and keep secrets out of screenshots and CI logs. Search terms such as tempail or temp gamil com may lead someone to a quick mailbox, but they are not a substitute for a controlled test contract.

A useful failure report answers these questions:

  1. Which worker created the address?
  2. When did the application accept the signup?
  3. How many inbox polls happened, and what was the last status?
  4. Was a message found but rejected by the predicate?
  5. Did the final link target the staging environment?

If the message exists but the predicate rejects it, the application and mail transport may be healthy; the fixture contract is stale. That distinction saves a lot of debugging time.

A QA checklist for parallel CI

Before enabling the test on every pull request, check the following:

  • Use one isolated address per worker and run.
  • Give the poller a deadline, interval, and maximum request count.
  • Match a correlation ID, not only a subject line.
  • Mark consumed message IDs so retries do not reuse the same email.
  • Redact tokens, addresses, and message bodies from normal logs.
  • Store a bounded receipt when the assertion fails.
  • Test delayed delivery, duplicate delivery, and an expired link seperately.
  • Run one intentional provider-error case so the report stays useful.

Do not make the timeout unlimited just because email is slow. A generous limit can hide a queue regression for weeks. Choose a limit based on the environment, then review the distribution of delivery times when the system changes.

Q&A: practical edge cases

Should every email test use a temporary address?

No. A stable local fake transport is usually faster for unit and component tests. Use an isolated external inbox for the smaller set of end-to-end checks that prove the deployed mail path.

Is polling always better than webhooks?

Not always. A provider webhook can reduce latency, but it adds another delivery path to test. Polling is often easier to reason about in a smoke test if the deadline and filtering rules are explicit.

What should happen when a duplicate arrives?

Keep the message IDs in the receipt and fail if more than one valid message matches unexpectedly. Duplicate delivery may be a valid retry behavior, but the application should define whether consuming the first link makes the second harmless.

Deterministic polling does not make email instant. It makes the waiting visible, bounded, and diagnosable. That is a modest change to a Playwright fixture, but it turns a flaky end-to-end check into a QA signal the team can trust.

Top comments (0)