Email verification is one of those React features that looks finished until the test suite runs in parallel. One test creates an account, another waits for a code, and a third cleans up the inbox. When they share an address, the result is not a product bug. It is a test design bug that feels random.
The fix I keep coming back to is a run-scoped contract: every test run gets a unique mailbox, every message lookup is bounded, and cleanup has an owner. This makes React email tests easier to reason about locally and in CI.
Why email tests become flaky in React projects
Browser tests usually have at least three asynchronous systems involved: the UI, the application API, and the email provider. A test that only waits for a button or a URL is not waiting for the whole workflow.
Shared inboxes add another race. A verification code from a previous run can satisfy the current assertion. A parallel worker can consume the message first. A retry can even create a second account while the first message is still arriving. The test don't fail consistently because the system is timing-sensitive, not because the assertion is always wrong.
Before adding longer sleeps, I like to make the fixture boundaries visible. A run ID, user ID, and expected message purpose should travel together. For more ideas on storing evidence that can be inspected after CI finishes, see these traceable email fixtures. If parallel workers are part of your setup, parallel-worker email tests are a useful related pattern.
Give every test run its own inbox contract
An inbox fixture does not need to expose every provider detail to the test. It needs a small, predictable interface:
type EmailFixture = {
address: string;
waitForCode: (options?: { timeoutMs?: number }) => Promise<string>;
listMessages: () => Promise<Array<{ subject: string; text: string }>>;
dispose: () => Promise<void>;
};
The important part is ownership. address belongs to one test run. waitForCode filters by the account or run token, not only by subject. dispose is safe to call in a finally block, even if the test failed before the first message arrived.
For local development, a temporary mailbox can be convenient when you need to get temporary email quickly. In CI, however, the fixture should still enforce isolation, expiry, and cleanup. A temp org mail address copied into several tests is just another shared global with a nicer name.
A small TypeScript fixture factory
Keep provider calls behind a factory so the React test only sees behavior it needs. The factory can add a unique suffix and return the resulting cleanup handle.
export function createEmailFixture(runId: string): EmailFixture {
const address = `signup-${runId}@test.example`;
return {
address,
async waitForCode({ timeoutMs = 30_000 } = {}) {
const message = await pollForMessage({
recipient: address,
subject: "Verify your account",
timeoutMs,
});
return extractVerificationCode(message.text);
},
async listMessages() {
return fetchMessages(address);
},
async dispose() {
await deleteMailbox(address);
},
};
}
The implementation behind pollForMessage should use a deadline and a short interval, not an unbounded loop. It should also record the message ID or a redacted subject when it succeeds. That evidence is a little more safer than logging the full email body, which may contain tokens or personal data.
In a React test, the lifecycle becomes straightforward:
const email = createEmailFixture(`invite-${testInfo.workerIndex}-${Date.now()}`);
try {
await page.goto("/signup");
await page.getByLabel("Email").fill(email.address);
await page.getByRole("button", { name: "Create account" }).click();
const code = await email.waitForCode();
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify" }).click();
} finally {
await email.dispose();
}
Keep cleanup and evidence explicit
Cleanup is not only about deleting a mailbox. It is also about preventing stale data from making the next run look healthy. Set an expiry on the fixture, make disposal idempotent, and record enough metadata to diagnose a failure: run ID, worker, recipient hash, wait duration, and message ID.
Avoid putting verification codes, complete addresses, or message bodies into CI logs. A redacted receipt still tells you whether the message arrived, how long it took, and which test owned it. Its tempting to log everything during a failure, but those logs often outlive the test data policy.
If your application has a local email sink, use the same fixture interface against that sink. The test should not care if the backing implementation is a local server, a provider sandbox, or a controlled temporary mailbox. The contract is what makes the swap useful.
A practical checklist for CI
Before merging an email-driven React test, check that:
- Each worker receives a unique run-scoped recipient.
- Message matching includes the intended account or run token.
- Polling has a deadline and produces a useful timeout error.
- Cleanup runs in
finallyand can be repeated safely. - Logs contain redacted evidence rather than secrets.
- Expired fixtures cannot be reused by a later run.
- Retries create a fresh fixture or explicitly reuse the same run.
The setup is easy enough, but cleanup are where most suites lose their reliability. Treat the email fixture as a resource with a lifecycle, just like a database connection or a browser context.
Final thoughts
Reliable email verification tests are less about finding a faster inbox and more about making ownership impossible to misunderstand. Give each run a scoped address, make message matching specific, and leave a small failure receipt behind.
That contract lets a React team move from “rerun it and hope” to tests that explain what happened. It also keeps the provider choice flexible, so a better fixture implementation can be introduced without rewriting every UI flow.
Top comments (0)