Email verification tests often fail in a very unhelpful way. The test says that a link was not found, but it does not tell you if the message was late, the wrong user received it, or the UI submitted twice.

In React projects, I have found that the fix is less about adding more waits and more about giving every step an explicit contract. TypeScript is a nice fit here: it makes the test data describe the user intent, while the React component can render a small and predictable state machine.

Why email tests become ambiguous

A typical test might create a user, submit a form, poll an inbox, click a link, and assert that the account is verified. That is a lot of behavior compressed into one scenario. When it fails, the browser report usually leaves you guessing.

There are three identities to keep separate:

  • the account being created;
  • the mailbox that should receive the message;
  • the verification attempt currently visible in the browser.

If those identities are represented by loose strings, a test can pass with the wrong message or fail after a perfectly valid retry. It look simple in a small suite, but the ambiguity grows quickly when parallel CI jobs are added.

Model the flow as a typed contract

Start with the states your product actually exposes. Do not model every internal implementation detail. A compact contract is easier to assert and easier to change.

type VerificationState = | { kind: "idle" } | { kind: "sending"; attempt: number } | { kind: "sent"; messageId: string; expiresAt: string } | { kind: "verified"; verifiedAt: string } | { kind: "failed"; reason: "expired" | "mismatch" | "delivery" }; type VerificationFixture = { userId: string; email: string; expectedSubject: string; state: VerificationState; }; 

Now the React screen has meaningful branches instead of a collection of booleans such as isLoading, hasError, and isVerified. Those booleans can accidentally describe impossible combinations. A discriminated union cannot prevent every product bug, but it makes the intended behavior visible in both the component and the test.

Build fixtures around intent

The fixture should say what the test is trying to prove. For example, verificationFixture("new-user") is more useful than a random email plus a comment explaining why it exists.

function verificationFixture(intent: "new-user" | "email-change"): VerificationFixture { const suffix = crypto.randomUUID(); return { userId: `qa-${suffix}`, email: `${intent}.${suffix}@example.test`, expectedSubject: intent === "new-user" ? "Confirm your account" : "Confirm your new email", state: { kind: "idle" }, }; } 

The test can then assert the causal sequence, not just the final screen:

const fixture = verificationFixture("new-user"); await page.getByLabel("Email").fill(fixture.email); await page.getByRole("button", { name: "Create account" }).click(); const message = await inbox.waitFor({ to: fixture.email, subject: fixture.expectedSubject, }); expect(message.links).toHaveLength(1); await page.goto(message.links[0]); await expect(page.getByText("Email verified")).toBeVisible(); 

This also makes avoiding mixed-up confirmation links a fixture concern instead of a hope buried in the test order.

Make failure output useful

When a test fail, print the fixture intent, message ID, recipient, and the last known UI state. A failure should answer “what happened?” before a developer opens the email provider dashboard.

expect(message, `Verification message for ${fixture.email}`).toMatchObject({ subject: fixture.expectedSubject, to: fixture.email, }); 

Keep polling bounded and record each observed event. An event timeline is usually more valuable than increasing a timeout from five seconds to thirty. If the message arrives late, you can see that. If the wrong message arrives first, you can see that too.

For CI cleanup, give each fixture an owner and expiry. The same idea appears in email cleanup contracts in CI: temporary test data should have a lifecycle, not just a creation step.

Where a temp inbox fits

For local checks or a short staging investigation, an isolated tempmailso inbox can be useful for inspecting the actual message, links, and headers without mixing test mail into a personal account. Keep it scoped to non-sensitive test data and never treat a public or temporary inbox as an identity store.

Some teams call this a temp org mail flow in notes or scripts. Whatever label you use, the important part is the contract: recipient, expected subject, message ID, and expiry should stay attached to the test run.

A practical checklist

Before calling an email flow reliable, check that:

  • the fixture has a unique run-scoped identity;
  • the React UI exposes mutually clear states;
  • the test matches the recipient and subject, not only arrival time;
  • the link belongs to the same verification attempt;
  • retries and expiry have separate assertions;
  • polling has a deadline and an event log;
  • cleanup runs even after a failed test.

This pattern is a bit more easier to maintain than a large helper that hides every email operation. It keeps the product behavior visible, gives TypeScript a useful job, and turns a confusing inbox failure into a small piece of evidence a developer can act on.