DEV Community

Silviu Technology
Silviu Technology

Posted on

Playwright Email Tests Need Ownership Checks

An email test can pass for the wrong reason. The subject looks right, the link opens, and the build turns green. Later, someone discovers that the test read a message left by an earlier retry or by a different worker.

This is one of those failures that feels random until you ask a more precise question: how did the test prove that this message belongs to this run? A free throwaway email inbox can be useful for end-to-end testing, but an address by itself is not ownership evidence. The assertion needs a small contract around the message.

I have seen teams focus on the inbox provider first, especially when somebody searches for tp mail so or a free throwaway email during tool research. The provider matters, but the test design matters more. A stable inbox with weak matching rules is still a flaky test.

Why a matching subject is not enough

A common helper looks roughly like this:

const message = await inbox.waitForMessage({
  subject: "Verify your email",
});

await expect(message.link).toContain("/verify");
Enter fullscreen mode Exit fullscreen mode

It is easy to read and often works on a quiet developer machine. In CI, the same subject can appear more than once. A retry may leave an old message in the inbox. Parallel tests may share an address by accident. A product team may also reuse the subject for a resend flow.

The result is a false positive: the test checks a real verification message, but not necessarily the message created by the action under test. The failure appears much later, when a token is expired or a user reports that the wrong account was verified.

Another subtle problem is a timing assumption. A helper that selects the newest message can still choose an older message if the inbox API returns events out of order. “Newest” is a sorting rule, not proof of causality. The test needs an ownership signal.

The ownership proof a test should require

Before clicking the signup button, create a correlation value that is unique to the test attempt. Put it somewhere the email can carry it, such as a user-specific local part, a supported header, or a value in the request metadata. Then require more than one matching condition.

For a verification flow, my minimum contract is:

  1. The message arrived after the test recorded its trigger time.
  2. The recipient identifies the current test account.
  3. The subject or message type is correct.
  4. The body contains the current correlation value.
  5. The verification URL belongs to the expected environment.

Not every mail system exposes all five fields. That is fine. The important part is to choose the strongest available signals and record which ones were checked. If the body cannot contain a correlation value, use a unique recipient alias or a request identifier exposed by the application.

For example, an address like qa-checkout-7f3c@example.test gives a parallel worker its own destination. The random suffix is not a secret; it is simply a way to make accidental cross-test reads less likely. The test still needs to check the recipient and message time.

If your backend already emits verification events, it is worth keeping replay-safe verification receipts beside the browser evidence. That makes it easier to tell whether the browser missed a message or the application created the wrong one.

A Playwright helper with explicit correlation

Put the matching rules in one helper so each spec does not invent its own version. The helper below keeps a small evidence record and rejects messages that only look similar.

type MailMessage = {
  id: string;
  to: string;
  subject: string;
  text: string;
  receivedAt: string;
  links: string[];
};

type MailEvidence = {
  inbox: string;
  triggerAt: string;
  acceptedId?: string;
  rejected: string[];
};

async function waitForOwnedVerification(
  inbox: MailClient,
  recipient: string,
  correlation: string,
  triggerAt: Date,
): Promise<{ message: MailMessage; evidence: MailEvidence }> {
  const evidence: MailEvidence = {
    inbox: recipient,
    triggerAt: triggerAt.toISOString(),
    rejected: [],
  };

  const messages = await inbox.poll(recipient, { timeoutMs: 45_000 });

  for (const message of messages) {
    const receivedAt = new Date(message.receivedAt);

    if (receivedAt < triggerAt) {
      evidence.rejected.push(`${message.id}: before trigger`);
      continue;
    }
    if (message.to !== recipient) {
      evidence.rejected.push(`${message.id}: wrong recipient`);
      continue;
    }
    if (!message.subject.includes("Verify your email")) {
      evidence.rejected.push(`${message.id}: wrong subject`);
      continue;
    }
    if (!message.text.includes(correlation)) {
      evidence.rejected.push(`${message.id}: correlation missing`);
      continue;
    }

    evidence.acceptedId = message.id;
    return { message, evidence };
  }

  throw new Error(`No owned verification message found: ${JSON.stringify(evidence)}`);
}
Enter fullscreen mode Exit fullscreen mode

The example assumes poll returns messages within the timeout. Your client may use a callback or locator instead. That implementation detail is less important than the order of checks and the rejection reasons.

Do not log the full email body or verification token in a shared CI report. Keep the message id, recipient hash, timestamps, and rejection reason. A small, safe record is enough to explain most failures without creating a new privacy problem. For API-driven systems, auditing email-change API events is a useful companion pattern.

How to triage a rejected message

When the helper fails, read the evidence in this order:

  • Before trigger: the inbox had stale mail, or the clock used by the test is wrong.
  • Wrong recipient: an alias was reused, or the mail client returned messages from a wider scope.
  • Wrong subject: the product sent a different template, or the test observed an unrelated notification.
  • Correlation missing: the application dropped the request id, used the wrong template, or the test checked the wrong field.
  • No messages: investigate the application event, worker, provider, and inbox separately.

This turns “email timeout” into a useful diagnosis. It also helps reveal a dependancy on message ordering that was never part of the intended contract. In practise, the first rejected message often tells you more than the final timeout.

A repeatable checklist

Before merging an email test, check that it:

  • creates or leases an isolated recipient for the attempt
  • records the trigger time immediately before the product action
  • matches recipient, type, correlation, and environment where possible
  • rejects stale messages instead of silently choosing the latest one
  • records safe evidence for each rejected candidate
  • behaves consistently when the test is retried
  • cleans up the inbox or marks the lease as finished

If a teammate types tem email into a bug note, treat it as a signal to inspect the evidence, not as a new test rule. The typo is harmless; an undocumented matching assumption is not.

Q&A

Should I delete every old message before a test?

Not necessarily. Deleting old messages can hide useful evidence and introduce another race. It is usually safer to keep the inbox, record a trigger boundary, and reject anything that cannot prove ownership.

Is a unique email address enough?

It is a strong start, especially for parallel workers, but it does not prove that the message belongs to the current action. A worker can still read a delayed message from an earlier attempt. Add a timestamp and correlation check when the system supports it.

Should retries use the same correlation value?

Usually no. A retry should have its own attempt id, while the test report can connect both attempts to the same scenario id. This keeps the evidence honest and makes it clear whether the first attempt produced a late message.

Ownership checks add a little code, but they remove a lot of guesswork. Once every email assertion explains why its message belongs to the current run, Playwright failures become much more actionable.

Top comments (0)