DEV Community

ryanlee
ryanlee

Posted on

React Email Tests Need a Fixture Factory

Email verification is one of those React features that looks simple in the browser and becomes messy in CI. A form submits, a backend sends a message, and the test waits for a link. Then an older message is read, two workers share an inbox, or cleanup happens too late.

The fix is to treat email as a test fixture with a lifecycle, not as a random external dependency. A small fixture factory can create a unique address, record when the flow started, expose a narrow inbox adapter, and release everything when the test ends.

Why a shared inbox makes React tests flaky

Imagine two Playwright or component-driven end-to-end tests running this flow at the same time:

  1. Both tests create an account.
  2. Both ask for a verification email.
  3. Two messages arrive close together.
  4. Each test reads the newest message.

The test may pass locally and fail in CI because message order is not ownership. A longer timeout does not solve that. It only gives the wrong test more time to consume the wrong evidence.

The same problem appears when a previous run leaves a message behind. A selector such as “subject contains Verify” is helpful, but it does not prove that the message belongs to the current run. This is where a delivery lease for verification workers is a useful design reference: the mailbox needs an owner and an expiry, even when the owner is a test.

The fixture factory contract

Keep the public API small. The React test should not know how an inbox provider stores messages or how addresses are allocated.

type EmailFixture = {
  address: string;
  runId: string;
  requestedAt: string;
  waitForVerification(): Promise<{ url: string; messageId: string }>;
  close(): Promise<void>;
};

async function createEmailFixture(runId: string): Promise<EmailFixture> {
  const mailbox = await inboxProvider.reserve({ runId, ttlSeconds: 300 });
  const requestedAt = new Date().toISOString();

  return {
    address: mailbox.address,
    runId,
    requestedAt,
    waitForVerification: () => inboxProvider.waitForMessage({
      leaseId: mailbox.leaseId,
      after: requestedAt,
      subject: "Verify your account",
+      metadata: { runId },
    }),
    close: () => inboxProvider.release(mailbox.leaseId),
  };
}
Enter fullscreen mode Exit fullscreen mode

The factory records requestedAt before the application action. That detail is easy to miss. If the clock starts after the signup request, a fast message can be incorrectly treated as old. The lease ID also gives the provider a way to reject reads after cleanup.

In real code, make the provider dependency an interface and inject it into the factory. That keeps the React test fast with a fake provider, while one integration suite proves the real adapter. It is more setup at first, but the failures are much more useful.

A TypeScript inbox adapter

The adapter should return structured evidence instead of making the test parse arbitrary text. A minimal result can include the message ID, received time, and extracted verification URL.

type VerificationMessage = {
  messageId: string;
  receivedAt: string;
  url: string;
};

interface InboxProvider {
  reserve(input: { runId: string; ttlSeconds: number }): Promise<{
    address: string;
    leaseId: string;
  }>;
  waitForMessage(input: {
    leaseId: string;
    after: string;
    subject: string;
    metadata: { runId: string };
  }): Promise<VerificationMessage>;
  release(leaseId: string): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

The adapter should distinguish message_not_found, lease_expired, and provider_unavailable. Those errors lead to different actions. A missing message may need one bounded poll; an expired lease needs a new fixture; a provider outage should be reported instead of hidden behind five retries.

If the signup endpoint can retry, make its email event idempotent too. The same boundary used for idempotency for REST API retries applies here: a retry should not create an indistinguishable second message that the test might accept by accident.

Correlate messages with the test run

Use at least two signals when selecting a message:

  • a lower time boundary captured before the action;
  • a unique run ID in a provider-supported header, alias, or test field.

The address alone is not enough if workers reuse a mailbox lease. A fixture can contain noisy input such as temp gamil com, but that value is not a trusted identity and should never be the only selector.

Keep the verification assertion narrow as well. Check that the URL points to the expected environment, contains the expected account identifier, and is consumed only once. Do not log the full token when a redacted URL or message ID is enough for diagnosis.

Q&A: reuse, retries, and cleanup

Should every test reserve a new address?

Not always. A unique lease per worker can be sufficient when the adapter enforces correlation and rejects stale messages. For signup tests that mutate account state, a fresh fixture is usually easier to reason about.

Can I just retry until the email appears?

Use bounded polling with a deadline. Endless retries make a broken selector look like slow delivery, and the CI bill grows quietly. Record the final provider state so the next failure has a clue.

Where should cleanup happen?

Put fixture.close() in a finally block or the test runner's teardown hook. Cleanup should run after assertion failures too. A mailbox that stays leased can poison the next test run, which is a very confusing failure mode.

A practical checklist

Before calling a React email test reliable, check that:

  • each test has a unique run ID;
  • the fixture records its start time before signup;
  • reads require lease ownership and message correlation;
  • the inbox adapter returns structured evidence;
  • provider errors have distinct types;
  • polling has a firm deadline;
  • tokens are redacted from logs;
  • cleanup runs when assertions fail.

A fixture factory is a small layer, but it gives the test a clear contract: reserve, act, observe the right message, assert, and release. That makes React email flows easier to ship because the test is checking product behavior, not guessing which inbox event happened to be newest.

Top comments (0)