DEV Community

ryanlee
ryanlee

Posted on

React Test Flows Need a Fake Email Boundary

Signup tests often look like UI tests, but the hardest part is usually outside the browser. A user submits a form, the server sends a verification message, and the test has to find the right link before the UI can move on. When that mailbox is shared, a passing test can still be hiding a race condition.

The fix is to treat email as a test boundary with an explicit contract. A fake emails generator can provide the data, but your React test should own the mailbox, know which message belongs to it, and release it when the run ends. This keeps the product flow realistic without making every spec depend on the same inbox.

Why email fixtures become a React test boundary

An email verification flow has at least four independent events:

  1. The browser submits the signup form.
  2. The API creates a verification token.
  3. A message is delivered to a test mailbox.
  4. The browser opens the link and updates the account state.

If a test only waits for “the newest email,” event three is underspecified. A parallel test might receive the message, or an old message might look fresh enough. The test dont know whether it consumed evidence from its own signup.

I prefer a fixture that carries an ownerId through the whole flow. The browser uses the generated address, the API records the address with the test run, and the inbox query filters by both address and creation time. This is a small boundary, but it removes a surprising amount of noise.

For a broader view of message ownership, a consumption ledger for Playwright mail tests is a useful companion pattern. The idea applies just as well when the outer test is written around React.

Give every test a mailbox contract

Before writing a helper, define what the fixture promises:

  • create() returns a unique address and an owner identifier.
  • waitForVerification() only reads messages created after the signup started.
  • consume() marks the message as used so it cannot be reused by another assertion.
  • dispose() releases the mailbox even when the test fails.

The contract should also define failure states. “No message yet” is different from “mail provider unavailable,” and both are different from “message already consumed.” Returning those states makes the failure a bit more clear when it reaches CI.

Avoid hiding unlimited polling inside the helper. Give it a deadline from the test, then return the observed message IDs and the last check time in the error. That evidence is much more useful than a generic timeout after two minutes.

A small TypeScript fixture

Here is a deliberately small interface. The implementation can talk to a local mail catcher, a service API, or a fake in-memory store.

type Mailbox = {
  address: string;
  ownerId: string;
};

type VerificationMessage = {
  id: string;
  link: string;
  receivedAt: string;
};

interface TestInbox {
  create(): Promise<Mailbox>;
  waitForVerification(
    mailbox: Mailbox,
    options: { after: Date; timeoutMs: number },
  ): Promise<VerificationMessage>;
  dispose(mailbox: Mailbox): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The test can now make ownership visible instead of passing a global inbox into every component:

const mailbox = await inbox.create();

try {
  await page.getByLabel("Email").fill(mailbox.address);
  const startedAt = new Date();
  await page.getByRole("button", { name: "Create account" }).click();

  const message = await inbox.waitForVerification(mailbox, {
    after: startedAt,
    timeoutMs: 30_000,
  });

  await page.goto(message.link);
  await expect(page.getByText("Email verified")).toBeVisible();
} finally {
  await inbox.dispose(mailbox);
}
Enter fullscreen mode Exit fullscreen mode

The startedAt value should be captured before the action that triggers the email. Otherwise a fast delivery can arrive before the test starts looking, and the filter may hide the message by mistake. This detail is easy to miss, specially when the local environment is much faster than CI.

Keep the UI test focused

React tests become easier to maintain when the component is responsible for rendering states, while the fixture is responsible for external evidence. The UI test should assert that the submit button changes state, that errors are announced, and that a successful verification leads to the right screen. It should not know how many API requests the inbox needed.

For flows with a real browser, I also keep one end-to-end check for the complete path and use smaller component tests for loading, error, and success states. A less flaky email verification pattern follows the same separation: make the message signal deterministic, then let the UI assertion stay simple.

Do not confuse a dummy e mail address with a production identity. Test mailboxes should never be accepted as proof of a real customer, and the fixture should make that distinction visible in its naming and configuration.

Q&A: choosing the right boundary

Should every test create a new mailbox?

For signup and verification tests, usually yes. The small setup cost buys isolation. Reuse is reasonable for read-only component tests, but shared mail state is a bad default for flows that consume messages.

Is a fake inbox enough for end-to-end coverage?

It is enough for most deterministic CI checks. Keep a smaller number of tests against the real delivery integration for provider-specific behavior, such as templates or webhook handling. Those tests can have a different timeout and a clearer failure policy.

What should be logged when a test fails?

Log the owner ID, mailbox address in a redacted form, deadline, message IDs observed, and the final state. Do not log verification tokens or full message bodies. That gives developers enough context to fix the test without turning CI output into a secret dump.

An email fixture is not just a convenience helper. It is a boundary between a React interaction and an asynchronous system. Once the ownership, deadline, and cleanup rules are explicit, the signup test can focus on the feature—and your team can ship changes with less confusion.

Top comments (0)