Email verification is a small feature with a surprisingly large test surface. A user submits a React form, the API creates a token, a worker sends a message, and the browser eventually consumes a link. When the test reaches directly into an inbox, every layer starts leaking into the next one.
I prefer to treat the inbox as an adapter with a small contract. The test should ask for a message, wait for a matching subject or token, and return the link. It should not know how an inbox provider paginates messages or whether the delivery endpoint is slow today. This boundary makes the feature easier to ship and the failure easier to debug.
Why the inbox belongs in the test boundary
An auth test usually has three different clocks:
- The React state update after form submission.
- The backend transaction that creates the verification record.
- The email delivery path, which may finish later than the HTTP response.
If one Playwright test handles all three clocks with arbitrary sleeps, it becomes noisy. A five-second delay might hide a real regression, while a two-second delay can fail on a busy CI runner. The goal is not to make the test wait forever. Its to give each dependency a clear contract and a bounded deadline.
That is the same reason I like reliable Playwright email waits: the wait should explain what evidence proves that the next action is safe.
The inbox adapter can provide that evidence. The UI test only needs a function such as waitForVerificationLink, not a collection of provider-specific calls scattered through every spec.
Model the inbox as a typed adapter
Start with a tiny TypeScript interface. Keep the returned message intentionally narrow so tests do not become coupled to a vendor response shape.
export type TestMessage = {
id: string;
subject: string;
text: string;
receivedAt: string;
};
export interface TestInbox {
address: string;
waitForMessage(input: {
subjectIncludes: string;
after: string;
timeoutMs: number;
}): Promise<TestMessage>;
}
The after cursor is important. Without it, a retry can consume yesterday's verification email and pass for the wrong reason. A run-scoped inbox or a unique recipient alias is better still, because parallel tests should not compete for the same message.
The implementation can poll an HTTP endpoint, subscribe to an event, or read a local fixture. The React test does not need to care. It only needs a deterministic result or a useful error containing the last observed state.
A React and TypeScript implementation
Create the inbox before the signup action, then record the time immediately before submitting the form. That gives the adapter a clean lower boundary for matching.
const inbox = await createTestInbox({ runId });
const startedAt = new Date().toISOString();
await page.getByLabel("Email").fill(inbox.address);
await page.getByRole("button", { name: "Create account" }).click();
const message = await inbox.waitForMessage({
subjectIncludes: "Verify your email",
after: startedAt,
timeoutMs: 30_000,
});
const link = extractVerificationLink(message.text);
await page.goto(link);
await expect(page.getByText("Your email is verified")).toBeVisible();
This structure leaves the React component responsible for visible behavior: labels, loading state, validation, and the success screen. The inbox adapter is responsible for delivery evidence. Both parts stay testable without pretending they share one timing model.
It also makes a useful failure receipt possible. When the message does not arrive, include the recipient, subject filter, elapsed time, and the last message IDs seen. A test that only says “timeout” is hard to act on. A test that says “no matching subject after 30 seconds; two unrelated messages observed” points somewhere.
For a broader workflow, I also keep email checks without UI drift separate from visual assertions. The distinction saves a lot of rework when copy or layout changes but the delivery contract stays stable.
Keep polling and cleanup explicit
Polling is acceptable when its bounded and observable. Use a deadline rather than an unbounded retry count, and back off modestly so CI is not hammered while a message is in transit. The exact interval depends on the test service, but the rule is simple: the timeout belongs in the test configuration, not hidden inside a helper.
Cleanup deserves the same attention. Delete or expire the inbox after the test, even when an assertion fails. In practice, a try/finally around the scenario is enough:
const inbox = await createTestInbox({ runId });
try {
await runSignupScenario(page, inbox);
} finally {
await inbox.dispose();
}
The adapter dont need to expose every cleanup detail to the spec. It just needs one safe operation that the runner can call consistently. If cleanup is eventually consistent, record that status instead of blocking the next test on a perfect delete.
Tradeoffs for local and CI runs
A real temporary inbox gives a realistic delivery path, but it introduces network and provider failure into the test. A local fake is fast and repeatable, but it can miss sender configuration, template rendering, or link-host mistakes. A practical split is:
- Run fast adapter-backed tests on every pull request.
- Run a smaller delivery smoke test on a schedule or before release.
- Keep recipient data run-scoped and never use personal addresses.
- Log message IDs and correlation IDs, but redact tokens from normal output.
If a developer searches for a service and types tamp mail com by mistake, that should not change the test contract or become a hidden fallback. Search input, inbox selection, and verification evidence are seperate concerns. Keeping them separate makes the workflow safer and the result more predictable.
Quick questions
Should the React test inspect the email HTML?
Only when rendering is the behavior under test. For most auth flows, extracting a link from normalized text is enough. Add one focused template test for HTML details instead of repeating those assertions in every browser scenario.
Should every test get a new inbox?
Every parallel or stateful scenario should get an isolated address, alias, or message namespace. Reusing an inbox can be fine for a sequential unit test, but it raises the chance of consuming stale mail.
What should fail first?
Fail on the API response, then the inbox deadline, then the UI assertion. Each failure should identify its boundary. That ordering turns a flaky-looking signup test into a short debugging path.
Final checklist
- Give each run an isolated inbox or message namespace.
- Record a timestamp or cursor before submitting the form.
- Match on a stable subject and the intended recipient.
- Bound polling with a visible timeout.
- Return a small typed message object to the test.
- Dispose of the inbox in
finally. - Keep tokens out of ordinary logs.
An inbox adapter is a small piece of code, but it changes the shape of the whole test. React owns the user experience, the API owns the verification contract, and the adapter owns delivery evidence. That separation lets each part move quicker without turning email verification into a guessing game.
Top comments (0)