DEV Community

Silviu Technology
Silviu Technology

Posted on

Playwright Email Fixtures Need Ownership Checks

Email verification tests often fail in a way that looks random: the signup succeeds, the inbox eventually receives a message, and the test still clicks the wrong link. A retry may pass. That is not a useful test result. It usually means the fixture was not isolated well enough, or the test never proved that the message belonged to its current run.

In my QA work, I treat an email fixture like any other test dependency. It needs an owner, a lifecycle, and evidence that can explain a failure. A use and throw email address can be convenient for a quick check, but convenience is not isolation by itself.

The failure pattern

Consider a flow that creates a user, waits for a verification email, and opens its link. A broad inbox query such as “get the newest message” has several hidden assumptions:

  • no previous test used the same address;
  • delivery order matches creation order;
  • a retry cannot leave an older message behind;
  • the inbox provider will return a stable sort order;
  • the subject alone identifies this test run.

Those assumptions are fragile. Parallel workers make them worse. The visible symptom is often a timeout, but the root cause may be a stale message selected a few lines earlier.

Build an owned fixture

Generate a unique address or mailbox identifier at the beginning of the test. Keep the value in the test's run context and pass it explicitly to the application. Do not hide it in a global variable or reconstruct it from a timestamp later.

Then give every message a correlation value. A simple pattern is a run token in the recipient, subject, or a supported metadata field:

const runToken = `pw-${Date.now()}-${testInfo.workerIndex}`;
const address = `qa+${runToken}@example.test`;

await page.getByLabel('Email').fill(address);
await page.getByRole('button', { name: 'Create account' }).click();
Enter fullscreen mode Exit fullscreen mode

The exact mailbox mechanism depends on your test environment. The important contract is that the test can ask for messages belonging to runToken, not merely messages that arrived recently.

Before polling, record the fixture facts: address, run token, worker, and the action that should produce the message. This small receipt is more useful than a screenshot when the test fails in CI.

A repeatable Playwright workflow

The polling loop should separate retrieval from validation. Retrieval asks whether a candidate exists. Validation proves that it is the right candidate.

await expect.poll(async () => {
  const messages = await inbox.list({ to: address });
  const owned = messages.find((message) =>
    message.subject.includes(runToken) &&
    message.to === address &&
    message.text.includes('Verify your account')
  );

  return owned?.id ?? null;
}, { timeout: 30_000, intervals: [500, 1000, 2000] }).not.toBeNull();
Enter fullscreen mode Exit fullscreen mode

After finding the message, fetch it by ID and validate the link before navigating. A message ID is a better handoff than carrying a whole mutable response through the rest of the test. Also assert that the link points to the expected environment; a valid-looking link from production is still a test failure.

For more context on making this boundary explicit, see these inbox contracts for stable Playwright tests. If retries are part of your strategy, capture inbox evidence for retry-heavy test flows instead of treating a retry as proof that the system is healthy.

Diagnose retries with evidence

When a test retries, compare the receipts from each attempt. Useful fields include the run token, request timestamp, message IDs returned by each poll, selected message ID, and the final URL host. This tells you whether the problem is delivery latency, duplicate delivery, query filtering, or navigation.

Avoid logging the full email body or verification token in shared CI logs. Store a redacted artifact when deeper inspection is needed. A temp mailid copied into a ticket can also become a credential-like leak, even if the address was intended only for testing.

One practical rule is to fail early when the inbox contains multiple plausible matches. Picking the newest match hides a contract violation. Report the candidate IDs and their timestamps, then fix the fixture or selector.

CI checklist

Before calling an email test reliable, check that:

  1. Each worker owns a distinct address or mailbox scope.
  2. The message query filters by recipient and run token.
  3. The selected message is fetched and validated by ID.
  4. The link host and path match the test environment.
  5. Poll attempts leave a redacted diagnostic receipt.
  6. Retries do not reuse stale fixture state.
  7. Cleanup runs even after assertion failures.

This workflow adds a little setup, but it changes the failure from “email was flaky” to a specific, actionable diagnosis. That is the useful boundary: Playwright should verify the product flow, while the fixture contract makes sure it is verifying the message owned by this test.

Top comments (0)