When a signup flow depends on an email verification message, the inbox is part of the test fixture—even if the test code treats it like a string. A fixture that creates a disposable email address generator resource but never closes it can leave old messages, leaked addresses, and flaky retries behind.
The fix is a small shift in ownership: create the inbox inside the test lifecycle, pass only the address to the React app, and make cleanup an explicit hook. This keeps React and TypeScript tests easier to reason about while making parallel CI runs less surprising.
Why email fixtures become a hidden test dependency
A basic end-to-end test often looks like this:
- Create an inbox.
- Enter its address into a signup form.
- Wait for a verification message.
- Click the link.
- Assert that the account is active.
The trouble starts when step one is hidden in a global helper. A failed test may leave an inbox alive. A retry may reuse an address with an old verification token. Two workers can accidentally poll the same mailbox. The UI looks broken, but the real bug is fixture ownership.
Email data also has a privacy dimension. Test messages can contain names, tokens, or reset links, so it is worth defining a privacy boundary for burner email tests before adding more automation. Cleanup is not only housekeeping; it limits how long that data exists.
Model the fixture as a resource with an owner
Give the fixture three clear operations:
-
create: allocate a unique inbox and return its address. -
waitForCodeorwaitForLink: read only the message needed by the test. -
dispose: delete the inbox or mark it for expiration.
The test owns the resource from create until dispose. That rule works whether the provider is a temporary inbox service, a local fake, or a mail API. A temp mail so provider can fit this shape, but the test should not care about its branding or transport details.
I also keep the full client behind a narrow interface. That makes it possible to use a fast in-memory fake for component-level checks and a real inbox only in the small group of browser tests that need it. A useful test workflow is to create, consume, and dispose the mailbox in one bounded flow, similar to reusable workflows for email API checks.
A typed React test fixture with cleanup
Here is a compact TypeScript shape. The adapter methods are intentionally boring; predictable code is useful at a flaky boundary.
type EmailFixture = {
address: string;
waitForVerificationLink(): Promise<string>;
dispose(): Promise<void>;
};
type EmailProvider = {
createInbox(): Promise<EmailFixture>;
};
export async function withEmailFixture<T>(
provider: EmailProvider,
run: (fixture: EmailFixture) => Promise<T>,
): Promise<T> {
const fixture = await provider.createInbox();
try {
return await run(fixture);
} finally {
await fixture.dispose();
}
}
The finally block matters more than the helper name. It runs after an assertion fails, a navigation throws, or a verification wait times out. In a Playwright test, the callback can fill the React form with fixture.address, wait for the message, and visit the returned link. The application receives a normal email string and stays unaware of test-only cleanup.
For a test runner with beforeEach and afterEach, store the fixture in the test scope rather than a module-level variable. Module state is convenient until workers run in parallel. If disposal can fail, record that failure with the test context while still allowing the original assertion error to remain visible; otherwise cleanup noise can hide the useful failure.
Make retries and parallel tests safe
Unique addresses are the first line of defense. Do not derive an address from a fixed username such as signup@example.test when multiple workers can run together. Include a run ID, worker ID, or provider-generated identifier instead.
Next, make message matching strict. Match the recipient, an expected subject or event ID, and a recent time window when the provider supports it. Do not simply grab the newest message: an old retry can make a broken test pass.
Finally, use bounded polling. The wait helper should have a deadline and a useful error that includes the test name and recipient, but never the full verification token. If the provider has eventual consistency, a short retry is expected. An unbounded loop is just a hung CI job wearing a friendly disguise.
If the provider offers an API, keep its create and delete calls in one small adapter. The adapter can use a temp org mail address during local experiments, but production-like tests should use a controlled test domain and redacted logs.
A practical cleanup checklist
Before merging an email-dependent test, check:
- Does every created inbox have one owner?
- Is disposal in
finallyor the runner's guaranteed teardown? - Can two workers create distinct addresses?
- Does message matching reject stale mail?
- Is polling bounded and cancellation-aware?
- Are tokens, message bodies, and addresses redacted from CI logs?
- Does cleanup run when the browser step fails?
If the answer to one item is no, the test may still pass locally, but it has a fragile contract. Fixing that contract now is usually cheaper than debugging a failure that only appears after a retry.
Common questions
Should component tests create real inboxes?
Usually no. Mock the provider at the component boundary and test the loading, success, timeout, and error states directly. Reserve real inboxes for integration or browser tests where delivery is the behavior under test.
Is a disposable email address generator enough for CI?
It is only one part of the solution. You still need unique allocation, strict message matching, bounded waits, and guaranteed disposal. A provider can supply the mailbox, but your fixture owns the lifecycle.
What if disposal is unavailable?
Use short expiration, namespace each run, and remove sensitive message content from logs. Treat the missing delete operation as a limitation to document, not a reason to skip ownership. The tempmailso link in a test note is not a cleanup strategy by itself.
The most useful improvement is small: make email setup look like a resource allocation, not a global convenience function. Once React tests can create and dispose their own fixtures, retries become less mysterious, CI gets cleaner, and the verification flow is easier to change without a pile of hidden state.
Top comments (0)