DEV Community

ryanlee
ryanlee

Posted on

React Email Tests Need a Message Truth Table

React Email Tests Need a Message Truth Table

Email verification tests usually fail in a confusing place: the UI is waiting, the inbox has several messages, and the test cannot explain which message should count. Adding another retry may make the red build disappear for a while, but it does not make the test more trustworthy.

I have found a better boundary in React projects: define a small truth table for the message before wiring it into the component. A TypeScript adapter decides whether an event is new, relevant, and complete. React renders the result without knowing about inbox polling.

The message is part of the test contract

An email is not just “something arrived.” For a verification flow, the test normally needs to answer a few specific questions:

  • Does this message belong to the current signup attempt?
  • Is it newer than the test's start time?
  • Does it contain the expected action, such as a verification URL?
  • Has this exact message already been consumed?
  • If it is not usable, is the reason visible in the failure report?

Writing these questions down makes the design less fuzzy. The inbox boundary becomes a product of the test run, not a bag of messages that the component happens to poll.

This is also where stable scenario IDs help. A scenario ID gives the message matcher something stronger than a timestamp when several tests run close together.

Model message identity explicitly

Keep provider data at the edge and convert it to a small internal type. The exact fields depend on the provider, but the contract can stay simple:

type InboxMessage = {
  id: string;
  receivedAt: number;
  subject: string;
  body: string;
  scenarioId?: string;
};

type MessageDecision =
  | { kind: "ignore"; reason: "old" | "wrong-scenario" | "already-seen" }
  | { kind: "accept"; message: InboxMessage };
Enter fullscreen mode Exit fullscreen mode

The matcher should be a pure function where possible. That makes it easy to test with old messages, duplicate events, and messages from a different signup. It also prevents a provider-specific status code from leaking into a React event handler.

function decideMessage(
  message: InboxMessage,
  startedAt: number,
  scenarioId: string,
  seen: Set<string>,
): MessageDecision {
  if (seen.has(message.id)) return { kind: "ignore", reason: "already-seen" };
  if (message.receivedAt < startedAt) return { kind: "ignore", reason: "old" };
  if (message.scenarioId !== scenarioId) {
    return { kind: "ignore", reason: "wrong-scenario" };
  }
  return { kind: "accept", message };
}
Enter fullscreen mode Exit fullscreen mode

This is deliberately not clever. Explicit branches are easier to review than filters spread across a polling loop. Providers do not always give a scenario ID, so the adapter may need a secondary correlation rule for local fixtures.

Turn inbox events into a truth table

The matcher becomes more useful when its cases are written as a table. For example:

Event Current run? Seen? Decision
Old verification message No No Ignore as old
New message for another run No No Ignore as wrong scenario
New matching message Yes No Accept
Same message delivered twice Yes Yes Ignore as already seen

This table is a compact design review. If the team cannot agree on a row, the test does not yet have a reliable definition of success. It gives you focused unit cases instead of one enormous end-to-end test.

When polling, record the last cursor and the decision reasons, but redact verification tokens and full message bodies. A timeout should say that three messages were ignored as old and one had the wrong scenario. That is far more actionable than “expected email, received none.” Patterns for traceable email failures fit naturally here because the decision log is useful to both local tests and CI.

Keep React state boring

React should render the outcome, not own inbox rules. A small state union is enough:

type VerificationState =
  | { status: "idle" }
  | { status: "waiting"; address: string }
  | { status: "verified"; messageId: string }
  | { status: "failed"; reason: string };
Enter fullscreen mode Exit fullscreen mode

The adapter can expose waitForMessage, while a hook translates its result into this state. That separation keeps loading and error UI predictable, and it makes accessibility work easier: waiting can use a polite status region, while a terminal failure can use an alert.

Avoid putting a provider cursor, poll interval, or raw inbox response in component state. Those implementation details make rerenders harder to reason about and can cause a stale closure to accept a message from the previous attempt. It is a small distinction, but it save debugging time.

Make cleanup and evidence predictable

Create the inbox and its seen set inside the test scope. Dispose both through a finally block, including when an assertion fails:

const inbox = await createInbox({ scenarioId });

try {
  await submitSignup(inbox.address);
  const message = await inbox.waitForMessage({ timeoutMs: 30_000 });
  await openVerificationUrl(message);
} finally {
  await inbox.dispose();
}
Enter fullscreen mode Exit fullscreen mode

If a team uses tempmailso for an isolated test inbox, keep the address tied to the run and put its lifecycle in the fixture that owns the scenario ID. Do not put live verification tokens in screenshots or CI logs. A temp mailid can be fixture text, but it should not become a real credential in a copied test.

A small review checklist

Before merging an email test adapter, ask:

  • Can the matcher distinguish old, duplicate, and wrong-run messages?
  • Is the accepted message tied to a scenario or another explicit correlation key?
  • Are ignore reasons available when the test times out?
  • Does React receive a small state union rather than provider responses?
  • Does cleanup run after both success and failure?
  • Are tokens and message bodies kept out of logs?
  • Are fixture oddities such as tepm mail com treated as data, not silently repaired?

If the answer is no, more polling usually creates noise. Fix the contract first.

Final thoughts

Reliable React email tests come from making message identity explicit. A short TypeScript truth table handles stale, duplicate, and cross-run events before they reach the UI. React then gets a small, honest state model, while CI gets evidence that explains what happened.

That boundary is modest, but it helps a team ship verification features with less confusion—and fewer mysterious green tests that were only lucky.

Top comments (0)