DEV Community

ryanlee
ryanlee

Posted on

Node.js Email Tests Need a Message Cursor

Email verification tests often fail for a reason that looks random but is actually predictable: the test asks an inbox for the latest message instead of asking for a message created after this run started.

That difference matters when several CI jobs use the same disposable email account, when a retry leaves an old message behind, or when the provider sorts messages by a timestamp with limited precision. Increasing the timeout can hide the problem for a while. It does not tell the test which email belongs to it.

The practical fix is a message cursor. Record the inbox position before sending the verification email, then poll only for messages after that position. This small boundary makes a temporary email fixture much easier to reason about in Node.js, TypeScript, and browser tests.

Why “latest message” is a weak test contract

Imagine a signup test with this flow:

  1. Create an inbox.
  2. Submit the React signup form.
  3. Read the newest email.
  4. Extract the verification code.

Step three has hidden assumptions. The newest message might be from a retry. A parallel worker might send mail to the same address. A provider might return messages in a slightly different order than the UI displays them. The failure become especially confusing when the test passes locally and fails only on a busy runner.

I prefer treating the inbox as an event stream. A test records where it is before the action, performs the action, and consumes only events after that point. This is the same kind of explicit boundary used when designing email as a deployment contract: the consumer should be able to prove which event it received.

Define a cursor before sending mail

A cursor can be an opaque provider ID, an RFC 3339 timestamp, or a local sequence number. An ID is usually safest. If the provider only gives timestamps, keep a small overlap window and filter by a stable message ID after fetching.

The fixture contract should be clear:

  • mark() returns the current position before the product action.
  • waitFor() returns only a matching message after that position.
  • waitFor() has a deadline and reports what it observed.
  • dispose() releases the inbox even when an assertion fails.

The matching rule should include more than the subject. A run token in the recipient or message body gives the test another guard against cross-run messages. For example, signup-${runId} is more useful than a generic “Verify your account” subject. The matcher need to reject messages that do not carry that identity.

Build a small Node.js and TypeScript fixture

Keep provider calls behind a tiny client. The test should not know whether the inbox is a local mail catcher or an external service.

type Cursor = { afterId: string | null };

type VerificationMessage = {
  id: string;
  to: string;
  text: string;
};

type MailFixture = {
  address: string;
  mark(): Promise<Cursor>;
  waitFor(
    cursor: Cursor,
    predicate: (message: VerificationMessage) => boolean,
  ): Promise<VerificationMessage>;
  dispose(): Promise<void>;
};
Enter fullscreen mode Exit fullscreen mode

The implementation of waitFor can poll with a short delay and a monotonic deadline. It should remember the last cursor it inspected, so each request does not scan the whole inbox again. When no message arrives, include the run ID, recipient, and last observed message IDs in the error. That extra context saves a surprising amount of debugging time, specially on a busy runner.

Avoid making the polling loop unbounded. A test that waits forever is not more reliable; it is only harder to diagnose. Also, do not silently accept the first message returned by the API. Filter it by the cursor, recipient, and run token.

Connect the cursor to a React test

The component can stay focused on the user flow. Provisioning and email inspection belong in the test fixture:

const fixture = await createMailFixture(`signup-${testInfo.workerIndex}`);
const cursor = await fixture.mark();

try {
  await page.getByLabel("Email").fill(fixture.address);
  await page.getByRole("button", { name: "Create account" }).click();

  await expect(page.getByText("Check your email")).toBeVisible();

  const message = await fixture.waitFor(cursor, (item) =>
    item.to === fixture.address && item.text.includes("signup-")
  );

  const code = extractVerificationCode(message.text);
  await page.getByLabel("Verification code").fill(code);
  await expect(page.getByText("Account verified")).toBeVisible();
} finally {
  await fixture.dispose();
}
Enter fullscreen mode Exit fullscreen mode

This separation gives each failure a useful location: the form did not submit, the message did not arrive, the wrong message was rejected, or the verification code was invalid. A typo such as tepm mail com in a debug note should not become a fake matching rule; keep search terms and fixture identity explicit.

The pattern also works with React Testing Library when the email API is mocked at the Node.js boundary. The UI test can assert loading and error states while the fixture tests separately prove cursor behavior. That split keep the browser test quick without throwing away the important integration check.

Clean up without hiding useful failures

Cleanup is part of correctness, not a courtesy. A leftover inbox can leak test data into the next run and make “latest message” bugs return later. Give each test or worker ownership of its fixture, and always dispose it from a finally block.

For external providers, add an expiry as a second safety net. The expiry should be short enough to control retention, but long enough for a slow CI retry. These expiry rules for disposable email fixtures are easier to enforce when the test owns a single clearly named resource.

If cleanup fails, preserve the original assertion error and report cleanup as additional context. Replacing a failed signup assertion with “could not delete inbox” is not helpful. The cleanup result can go into the CI artifact or test report for later repair. These checks is most valuable when the report keeps both failure causes visible.

A message-cursor checklist

Before shipping an email-based test suite, check these points:

  • Is the cursor captured before the product action?
  • Can parallel runs use the same inbox without accepting each other’s messages?
  • Does the matcher verify recipient and run identity, not just subject?
  • Does polling have a deadline and actionable diagnostics?
  • Does every fixture have an owner and an expiry?
  • Does cleanup run when the browser assertion fails?
  • Are provider details isolated from the React component?

A cursor is a small piece of state, but it changes the test from “find something recent” to “prove this action produced that message.” That is a much stronger contract. It makes temporary email tests less flaky today and gives future contributors a clean place to extend the fixture when the signup flow grows. It also make failures easier to discuss with the team.

Top comments (0)