DEV Community

ryanlee
ryanlee

Posted on

TypeScript Email Fixtures Need One Owner

Email verification tests often fail for a reason that has nothing to do with React. The browser creates one address, the API helper stores another, and the cleanup step guesses which inbox should be deleted. The assertions look reasonable in isolation, but the whole test is carrying three slightly different stories.

I have found that the simplest fix is to give the email fixture one owner. A typed object creates the address, exposes the identifiers that the UI and API need, and owns cleanup. This makes the test easier to read and makes a failed run easier to inspect.

The pattern is useful whether the fixture comes from a local mail server, a hosted test inbox, or a burner email generator. The provider can change. The contract between the test and the fixture should not.

The hidden owner problem in email fixtures

Consider a signup test with these helpers:

const address = await createTestAddress();
await page.getByLabel("Email").fill(address.email);
await api.startSignup({ email: address.email });
await waitForVerification(address.id);
await deleteAddress(address.email);
Enter fullscreen mode Exit fullscreen mode

This looks tidy, but ownership is split across several values. The browser knows the email, the polling helper knows an ID, and cleanup receives a string that might not identify the same resource. A retry can create a second address and leave the first one behind.

The harder bug appears when a test fails before the verification message arrives. The failure says “timed out,” but it doesnt say which fixture was created, which run requested it, or whether cleanup even started. A developer then searches a mailbox by subject and may inspect stale mail from a previous run.

The goal is not to build a large framework. It is to make the lifecycle visible in one small value:

type EmailFixture = {
  runId: string;
  address: string;
  mailboxId: string;
  verificationToken?: string;
  cleanup: () => Promise<void>;
};
Enter fullscreen mode Exit fullscreen mode

Define a typed fixture contract

The factory should return the complete fixture, not just an address. A run ID gives logs a stable search key, while a provider-specific mailbox ID gives cleanup an unambiguous target.

type MailProvider = {
  createMailbox(input: { label: string }): Promise<{
    id: string;
    address: string;
  }>;
  deleteMailbox(id: string): Promise<void>;
};

async function createEmailFixture(
  mail: MailProvider,
  runId: string,
): Promise<EmailFixture> {
  const mailbox = await mail.createMailbox({ label: `signup-${runId}` });
  let cleaned = false;

  return {
    runId,
    address: mailbox.address,
    mailboxId: mailbox.id,
    cleanup: async () => {
      if (cleaned) return;
      cleaned = true;
      await mail.deleteMailbox(mailbox.id);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

The closure makes cleanup idempotent. That matters because a browser test can call it in a normal success path and a test runner can call it again from a global failure hook. Calling cleanup twice should not create a second failure that hides the original one.

It is also a useful boundary for test data. If a provider needs a special delete request or an expiry option, that detail stays in the factory. The React test only sees the contract it needs.

Keep React and the API on the same fixture

The UI should not invent a second address. Pass the fixture into the test steps, and use its value everywhere the signup flow needs it.

test("verifies a new account", async ({ page, api, mail }) => {
  const fixture = await createEmailFixture(mail, crypto.randomUUID());

  try {
    await page.goto("/signup");
    await page.getByLabel("Email").fill(fixture.address);
    await api.createSignup({ email: fixture.address });

    const message = await pollForMessage({
      mailboxId: fixture.mailboxId,
      subject: "Verify your account",
      timeoutMs: 15_000,
    });

    await page.goto(extractVerificationUrl(message));
    await expect(page.getByText("Email verified")).toBeVisible();
  } finally {
    await fixture.cleanup();
  }
});
Enter fullscreen mode Exit fullscreen mode

The important part is not the test runner syntax. It is that the browser, API, and mailbox poller all receive the same fixture. The verification step has one correlation path, so a failing run can be followed from signup request to message to cleanup.

For more ideas on making assertions express user intent, see these intent-driven signup assertions. A fixture contract complements that approach: the assertion describes what the user should experience, while the fixture describes which test data supports it.

Cleanup and failure evidence

Cleanup is necessary, but cleanup alone is not evidence. Write a small receipt when the fixture is created and when it is removed. Keep it free of message bodies and tokens.

type FixtureReceipt = {
  runId: string;
  mailboxId: string;
  addressHash: string;
  state: "created" | "message-found" | "deleted" | "cleanup-failed";
  observedAt: string;
};
Enter fullscreen mode Exit fullscreen mode

If the test times out, the receipt should still tell you whether the mailbox was created and whether the message poll ever found a matching subject. That is more usefull than a generic “email not received” line.

Do not log the verification URL, raw message, or access token. A correlation ID and a short-lived run identifier are enough for most debugging. If your team already has release-ready email checks, this receipt can become the small artifact that connects the browser assertion to the delivery check.

Search input can be messy too. Someone may type “tempail mail” or “tem email” while looking for a disposable test inbox. That spelling should never become part of the fixture identity. Use generated IDs internally, and keep provider search concerns behind the mail adapter.

A small review checklist

Before merging an email-based React test, I check:

  • Does one fixture own the address, mailbox ID, and cleanup operation?
  • Do the UI, API, and message poller use the same fixture object?
  • Is cleanup in a finally block and safe to call twice?
  • Can a failed run be connected to a mailbox without exposing its contents?
  • Does the polling deadline produce a useful state, rather than an endless wait?
  • Are old messages isolated by run ID, subject, or provider metadata?
  • Does the test avoid creating a new mailbox during every retry unless that is intentional?

The list is small, but it catches the bugs that tend to make email tests flaky. A fixture that has one owner is a bit more boring, and thats exactly what a test dependency should be.

Conclusion

Email verification tests cross several boundaries: React state, API requests, a message provider, and cleanup. Hidden ownership makes those boundaries harder to reason about.

A typed fixture gives the test one source of truth. It keeps the address and mailbox identity together, makes cleanup idempotent, and leaves enough evidence to explain a failure. Start with one factory and one contract; you dont need a new testing platform to get the benefit.

Top comments (0)