Email verification tests often look simple: create an address, submit a form, poll for a message, click the link, and continue. The trouble starts when a suite runs in parallel. Two tests can see the same subject, a retry can consume a message owned by the first attempt, or cleanup can remove the inbox while a browser is still collecting evidence.
The durable fix is to treat a test inbox like a leased resource. A lease gives one test ownership for a bounded period, while a message claim gives one test ownership of the specific verification event. This is a small change in test design, but it makes failures much easier to explain.
Why a fresh inbox is not enough
A new mailbox per test sounds isolated, but isolation has more than one layer. The address can still be reused accidentally by a retry, the provider can deliver messages out of order, and a broad subject match can select an older message. A test may pass while proving the wrong thing.
Start by writing down the contract for the fixture:
- One test run receives one unique mailbox identity.
- The mailbox expires after a short, explicit TTL.
- The test records a run ID and expected recipient, not the full message in logs.
- A matching message is claimed once and cannot be consumed by another worker.
- Cleanup runs after success, assertion failure, timeout, and cancellation.
This complements shared signup email rules across application layers. The frontend and API can agree on the recipient and event shape, while the Playwright fixture owns the lifecycle of the inbox itself.
The goal is not to make Testing sound more complicated. It is to prevent a green result from hiding a race condition.
Lease the inbox before the browser starts
Create the mailbox in a fixture before navigating to the application. Return a narrow object containing the address, run ID, expiration time, and operations the test actually needs. Avoid returning a provider admin client to every test; broad access makes accidental cross-test reads much easier.
type MailLease = {
address: string;
runId: string;
expiresAt: string;
waitForVerification: (opts: { subject: string }) => Promise<ClaimedMessage>;
release: () => Promise<void>;
};
The run ID should be unique across workers and retries. A practical value combines the CI job ID, Playwright worker index, retry number, and a short random suffix. Do not put a verification token or personal data in it.
The lease should be shorter than the overall job timeout. If a test can wait for ten minutes but the inbox remains available for an hour, a failed run leaves a large cleanup window. Expiry is a safety net, not a replacement for explicit release.
If your team uses a metadata label such as tempmailso or searches for “generate disposable email”, keep those terms out of runtime routing decisions. The routing key should be the opaque run ID and recipient address, because labels are not ownership proof.
Claim one message, not just one subject
Polling for a subject is a useful first step, but it is not a claim. The provider adapter should filter by recipient, expected subject, creation time after the lease began, and a unique correlation value in the message body or headers. It should then atomically mark the message as claimed.
const message = await lease.waitForVerification({
subject: "Confirm your account",
});
expect(message.recipient).toBe(lease.address);
expect(message.claimedBy).toBe(lease.runId);
await page.goto(message.verificationUrl);
A claim record needs an owner, claimed-at timestamp, and stable message ID. If a second poll sees the same ID, it should get a clear “already claimed” result rather than silently returning the message again. This is especially important for retries: a retry should create a new lease, not reopen the old one.
It also helps to separate “message found” from “message usable”. A found message may have a malformed link, the wrong environment host, or an expired token. Treat those as different failure categories so the report tells you whether delivery or application behavior broke. Email verification is a boundary, not a verdict that every downstream step is healthy.
A Playwright fixture with explicit cleanup
The fixture can wrap the lease in try/finally, with a second cleanup path handled by the provider’s TTL reaper:
import { test as base } from "@playwright/test";
export const test = base.extend<{ mail: MailLease }>({
mail: async ({}, use, testInfo) => {
const mail = await createMailLease({
runId: `${testInfo.project.name}-${testInfo.workerIndex}-${testInfo.retry}`,
ttlSeconds: 900,
});
try {
await use(mail);
} finally {
await mail.release();
}
},
});
Keep the fixture responsible for release, but let the test decide when to claim the message. That separation means a setup failure still cleans up, while the assertion remains close to the user flow it is proving. It is realy helpful when diagnosing a failure from a CI artifact several hours later.
Avoid putting the full email body into traces by default. Save a message ID, subject, recipient hash, delivery latency, and claim result. If a developer needs the body for a local investigation, make that an intentional opt-in with a short retention period. A fixture that always captures everything is convenient, but it creates informations that nobody reviews.
Diagnose the common failure modes
The poll times out. Check the delivery event, recipient, sender environment, and clock skew before increasing the timeout. A longer wait can hide a routing bug.
The wrong message is selected. Add a created-after filter and correlation value. Subject-only matching is neccessary only for very small demos, not a parallel CI suite.
A retry sees a consumed message. Give every retry a new lease and run ID. Never reset the claim flag just to make the retry pass.
Cleanup is flaky. Make release idempotent and rely on a TTL reaper for abandoned leases. A release call that fails halfway should not prevent later cleanup attempts.
The test passes locally but fails in CI. Compare worker identity, environment host, delivery latency, and clock values. Paralel workers often expose ownership assumptions that a single local worker never triggers.
Search data may contain odd phrases such as “fake e mail com”. That text is not a safe routing rule, and it doesnt establish that an inbox belongs to the current test.
A repeatable checklist
- Create the lease before the browser action that triggers delivery.
- Include worker, retry, and random data in the run ID.
- Filter messages by recipient, age, subject, and correlation value.
- Atomically claim a message and report duplicate claims clearly.
- Validate the destination host before opening a verification link.
- Keep tokens and message bodies out of routine logs and traces.
- Release in
finally; enforce a server-side TTL as the backup. - Give retries a new lease instead of reviving old state.
Q&A
Should every email test use a separate inbox? For parallel or security-sensitive flows, yes. A mailbox namespace can also work if message claims and recipient scoping are strict, but separate identities are simpler to reason about.
How long should the lease last? Start with the expected delivery time plus a measured buffer, then keep it below the CI job timeout. Review the value when delivery latency or test topology changes.
Does a disposable inbox solve all test privacy concerns? No. It reduces dependence on personal inboxes, but retention, credentials, logs, and message content still need limits.
The useful result is not merely a passing Playwright test. It is a test whose inbox, message, and browser action can be connected to one owner, one time window, and one clear failure explanation.
Top comments (0)