Signup email is usually treated like a small feature: submit a form, send a message, then show a success state. In a real React product, it becomes a boundary between browser state, an API, a mail provider, and a test inbox. When those concerns leak into each other, the UI gets hard to reason about and the tests become slow or flaky.
I have found it more useful to treat email delivery as a replaceable product dependency. The React screen should know that a verification request was accepted. It should not know if the message went through a provider API, a local fake, or a disposable email account used by an end-to-end test.
That separation makes a signup flow easier to ship, and it gives the team a cleaner place to inspect failures. The same idea works when a team searches for “temp mail so” while setting up a test workflow: the search phrase may vary, but the application still needs a stable contract.
The boundary is part of the product contract
The first useful question is not “which inbox should this test use?” It is “what does the UI need to know after the request?” Usually the answer is small:
- the request was accepted
- the user must check an inbox
- a retry is allowed after a defined delay
- a failure can be explained without exposing provider details
Those states belong in the product contract. Provider response codes, polling intervals, message IDs, and cleanup rules belong behind the boundary. Mixing them into a component creates a UI that knows too much and changes too often.
This is also where I separate a user-facing error from an engineering diagnostic. “We could not send that email” is useful in the browser. A structured event containing the provider response and correlation ID is useful in logs. Both can exist without making the component parse a provider SDK error.
What to keep inside the email adapter
An adapter should own the details that are likely to change. In practice, that includes:
- Creating or accepting the test inbox identity.
- Sending the verification request to the backend or provider.
- Waiting for a message when an end-to-end test needs it.
- Checking that the message belongs to the current signup attempt.
- Removing or expiring test data when the run finishes.
I like to record an attempt ID alongside the inbox address and trigger time. If a retry happens, the second attempt should not accidentally consume the first message. An audit trail for automated jobs is a useful mental model here: every action should leave enough evidence for a teammate to follow the story later.
The adapter can also normalize awkward provider behavior. One provider might return an empty list while delivery is pending. Another might return a message with a received timestamp but no parsed link. React should not carry those differences around like a backpack full of rocks.
A typed provider interface
TypeScript is handy here because the boundary becomes visible in the code. The interface should describe the small set of operations the product actually needs:
export type VerificationRequest = { email: string; attemptId: string; }; export type VerificationResult = | { status: "accepted"; attemptId: string } | { status: "rejected"; reason: "rate_limited" | "invalid_email" }; export interface EmailVerificationGateway { requestVerification(input: VerificationRequest): Promise<VerificationResult>; } The browser-side hook can depend on this interface rather than a vendor client:
export function useRequestVerification( gateway: EmailVerificationGateway, ) { const [state, setState] = useState<"idle" | "sending" | "accepted" | "failed">("idle"); async function request(input: VerificationRequest) { setState("sending"); const result = await gateway.requestVerification(input); setState(result.status === "accepted" ? "accepted" : "failed"); return result; } return { state, request }; } The test implementation can be tiny. The production implementation can add tracing and provider retries. Neither one needs to change the component's state machine. That is the real win, not the interface itself.
Testing the signup flow without real inbox noise
For component tests, use a deterministic gateway that returns accepted immediately. Test the loading state, disabled submit button, success copy, and retry affordance. These tests should not open an inbox at all.
For browser tests, use an isolated inbox per attempt and keep the inbox work in one helper. If your team already has a React inbox adapter, extend that boundary with attempt ownership instead of teaching each spec new polling tricks.
The browser test should read like a product journey:
await signupPage.submit({ email }); await expect(signupPage.successMessage).toContainText("Check your email"); const verification = await inbox.waitForLink({ attemptId, subject: "Verify your account", }); await page.goto(verification.url); await expect(accountPage.heading).toContainText("Welcome"); The provider-specific work stays in inbox.waitForLink. If a teammate types “tempail mail” during a rushed investigation, the test still has a clear contract to point back to. A small typo in a note should not become a hidden dependency in the suite.
Tradeoffs and a small rollout checklist
This pattern adds one more file and a little test setup. That cost is worth it when email behavior changes often or when multiple teams share the signup flow. For a tiny prototype, a direct call may be fine for now.
Before rolling it out, check these items:
- the UI handles accepted, rejected, and unexpected failures
- every attempt has a unique ID
- test inboxes cannot read each other's messages
- polling has a timeout and a useful error
- logs omit message bodies and private tokens
- cleanup runs even when the browser test fails
Keep the boundary boring. Fancy abstractions are not required; clear ownership is. The most common bug is still a boundary that exists in a diagram but not in the code.
Questions teams usually ask
Should component tests mock the full email provider?
No. Mock the small gateway interface used by the component. The provider contract gets its own integration coverage, while UI tests stay quick.
Is a disposable email account safe for production signups?
It is test data, not an identity policy. Use isolated test accounts for controlled environments and keep production abuse prevention separate from the test adapter.
Why not poll the inbox directly from every Playwright spec?
Because duplicated polling creates inconsistent timeouts, matching rules, and cleanup. One adapter gives the whole suite the same behavior, even when the wording in a failure report is a bit inconsistant.
The goal is simple: React owns the user experience, the backend owns the verification decision, and the email adapter owns delivery evidence. Once those responsibilities are explicit, signup tests stop feeling like a lucky chain of network calls and start behaving like a product tool.