Email verification looks like a small feature in a React app. A user enters an address, submits a form, waits for a message, and types a code. In a real product, that short journey crosses the browser, an API, a mail queue, and an inbox.
That makes it a surprisingly good place for flaky tests. A test may pass when the inbox is quick, then fail because a message arrived a few seconds later. It may also pass while checking the wrong message, or leak a real address into a shared test environment.
The fix I keep coming back to is a mailbox boundary. The UI test should know how to request a controlled test mailbox and read a verification message, but it should not know the provider's API, polling details, or cleanup rules. This keeps the product flow readable and the infrastructure replaceable.
Why the form needs an email boundary
A form test has two different responsibilities:
- Prove that the user can submit valid information and see useful feedback.
- Prove that the verification message can be found and used to finish the flow.
Without a boundary, those responsibilities get mixed together. A component test starts making network calls, a browser test knows provider-specific selectors, and a failed delivery becomes a mysterious timeout. The test is checking too many systems at once.
I prefer a small adapter with actions such as createMailbox, waitForCode, and dispose. The test talks in product language. The adapter handles the less interesting details. It is a bit more simple to debug when each failure has an obvious owner.
The same idea applies to other email-driven systems. For example, a delivery contract for alert emails makes the lifecycle visible instead of reducing it to one sent boolean.
Model the verification flow as states
Before writing selectors, write down the states the user can observe. A useful flow is:
idle -> submitting -> email-sent -> code-entered -> verified
\-> error
The names do not need to match your component exactly. What matters is that the test can distinguish “the request is still running” from “the request failed” and “the message has not arrived yet.” A single loading flag often hides those differences.
In React, the state can stay explicit and boring:
type VerificationState =
| { kind: "idle" }
| { kind: "submitting" }
| { kind: "email-sent"; address: string }
| { kind: "verified" }
| { kind: "error"; message: string };
This is useful for the UI and for the test. The test can assert that the user sees “Check your inbox” after submission, rather than waiting for an arbitrary delay and hoping the page changed.
Also, keep the code entry step separate from the initial form. It make retries easier to reason about: a resend is a new message attempt, while entering a code is a verification attempt.
Build a typed mailbox adapter
The adapter should expose only the operations the test needs. TypeScript helps make accidental provider coupling visible:
export type TestMailbox = {
address: string;
waitForCode(options?: { timeoutMs?: number }): Promise<string>;
dispose(): Promise<void>;
};
export interface MailboxFactory {
createMailbox(): Promise<TestMailbox>;
}
In a browser test, the factory can use a test-only service or a local fake. In a lower-level test, it can return a deterministic fixture. The important contract is the same: the caller gets an address, waits for a code with a bounded timeout, and cleans up when finished.
Avoid returning an entire provider response from this interface. Provider metadata is useful while implementing the adapter, but it becomes noise in every test that consumes it. If a test needs a message ID to diagnose a failure, expose that through structured logging or a debug artifact, not through every assertion.
Set a timeout that reflects the environment. “Wait forever until the email appears” turns an outage into a stuck CI job. A bounded wait gives the team a useful failure and preserves the original test context.
Test the user journey without brittle waits
A Playwright-style test should read almost like a product requirement:
test("user verifies an email address", async ({ page, mailboxFactory }) => {
const mailbox = await mailboxFactory.createMailbox();
try {
await page.goto("/sign-up");
await page.getByLabel("Email").fill(mailbox.address);
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText("Check your inbox")).toBeVisible();
const code = await mailbox.waitForCode({ timeoutMs: 15_000 });
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify email" }).click();
await expect(page.getByText("Email verified")).toBeVisible();
} finally {
await mailbox.dispose();
}
});
The test waits for facts, not time. It waits for the message through the adapter and waits for accessible UI text through the browser assertion. That is slower than a mocked promise, but it proves a more valuable slice of the feature.
For fast component tests, mock the mailbox boundary and return a known code immediately. Keep a smaller number of end-to-end tests for the real delivery path. This split gives quick feedback without pretending that a mock can find a broken email template.
What to keep out of the fixture
Test mailboxes should not contain real customer data. Generate only the fields needed for the scenario, and make the address unique per run. Do not print full message bodies or verification codes in normal CI logs. When a failure needs investigation, store a redacted artifact with a run ID instead.
It is worth documenting a privacy threat model for email fixtures, especially when multiple developers or CI jobs share an environment. Ownership, expiry, and cleanup are product-quality concerns too, not just test housekeeping.
Search terms around this problem are messy. A developer might write “temp mail,” “test mailbox,” or even tepm mail com while looking for a fixture. Those phrases can describe intent, but they should never become a hidden dependency in the test design.
A practical checklist
Before merging an email form test, check that:
- The form exposes explicit submitting, success, and error states.
- The mailbox provider is behind a small typed interface.
- The test uses a unique mailbox for each run.
- Message polling has a bounded timeout and useful diagnostics.
- UI assertions target labels and user-visible text.
- Cleanup runs even when an assertion fails.
- Fast unit tests and slower delivery tests have different jobs.
- Logs avoid unnecessary addresses, message bodies, and codes.
This boundary makes email verification less mysterious. React owns the user experience, the mailbox adapter owns delivery mechanics, and the test can prove the handoff between them. When the provider changes, the product test stays readable—and that is exactly the kind of maintenance win that keeps a feature shippable.
Top comments (0)