DEV Community

ryanlee
ryanlee

Posted on

React Forms Need a Test Email State Machine

Email verification looks like one step in a React form: submit, wait, and show success. In an end-to-end test, it is really a small distributed workflow. The browser starts an action, a backend sends a message, an inbox receives it, and the UI eventually consumes a token.

When that workflow is represented by isLoading and hasError, the test has to guess what happened. Was the message not sent? Did polling start too early? Did the fixture expire? Or did the UI simply miss a state change?

I have found a cleaner approach: model the email flow as a state machine, then let the React component render that machine. This makes the product behavior clearer and gives TypeScript tests useful evidence instead of another vague timeout.

Why a boolean is too small for email verification

A boolean works for a request with two outcomes. Email verification normally has more states:

  • the form is ready
  • the verification request is being sent
  • the inbox is being checked
  • a message was found and is being consumed
  • verification succeeded
  • the message expired
  • the test fixture failed

Collapsing all of those into loading hides useful distinctions. A test that fails while waiting for an inbox should not look identical to a test that receives a rejected token. The product team also needs different copy for those cases, especially when users are waiting on a real email.

This is where a state machine earns its keep. It is not a large library decision. It is a small contract between the form, the email client, and the test harness.

Model the flow as a discriminated union

In TypeScript, a discriminated union gives every state an explicit shape. The UI can no longer accidentally read a message id from a state where no message exists.

type VerificationState =
  | { kind: "idle" }
  | { kind: "sending"; startedAt: number }
  | { kind: "polling"; inbox: string; attempt: number }
  | { kind: "message-found"; messageId: string; receivedAt: string }
  | { kind: "verified"; verifiedAt: string }
  | { kind: "expired"; reason: "fixture" | "token" | "timeout" }
  | { kind: "failed"; code: string; retryable: boolean };
Enter fullscreen mode Exit fullscreen mode

Now a React component can render the state intentionally:

function VerificationStatus({ state }: { state: VerificationState }) {
  switch (state.kind) {
    case "idle":
      return <p>Ready to send a verification email.</p>;
    case "sending":
      return <p>Sending your email...</p>;
    case "polling":
      return <p>Checking the inbox (attempt {state.attempt})...</p>;
    case "message-found":
      return <p>Message received. Finishing verification...</p>;
    case "verified":
      return <p role="status">Email verified.</p>;
    case "expired":
      return <p>The verification window expired. Please try again.</p>;
    case "failed":
      return <p>We could not verify this email yet.</p>;
  }
}
Enter fullscreen mode Exit fullscreen mode

The missing default is useful. If a new state is added later, the compiler points at the render path that needs attention. That small bit of friction prevents a surprising amount of UI drift.

Keep inbox polling outside the UI

The component should not know how a disposable email inbox is queried. Give the flow a narrow dependency instead:

type VerificationInbox = {
  waitForMessage(input: {
    inbox: string;
    since: number;
    timeoutMs: number;
  }): Promise<{ id: string; receivedAt: string; token: string }>;
};
Enter fullscreen mode Exit fullscreen mode

In production, the implementation can call the service used by the application. In tests, it can use a controlled fixture. If the team needs to generate throwaway email addresses for an isolated run, keep that setup in the test adapter, not in the React component.

This separation also gives privacy a real boundary. A test can redact message bodies and retain only the id, timestamp, and assertion result. That lines up well with a privacy boundary for email test fixtures.

The adapter should report meaningful failures. TIMEOUT tells the test to inspect delivery or polling. TOKEN_REJECTED points at the application. FIXTURE_EXPIRED means the inbox needs a fresh lease. Those labels are much more helpful than "received null".

Make retries and expiry visible

Retries are fine when each attempt has an identity. Record the inbox, request time, polling attempt, and accepted message id. The test report should answer which inbox it used and why that message belonged to this run.

An expiry check is just as important. A fixture that remains alive after the test can leak messages into later runs and make a flaky test look green. Mark the fixture as closed when verification finishes, and close it again in cleanup when the test fails. Cleanup is easy to forget occassionally, so put it in one test helper.

For a retry, reset the state to sending with a new startedAt value. Do not keep an old message-found state around while the second request is running. That stale state can produce a false success, specially when the test uses a fast local mock.

If your team records CI evidence, include the state transition list in the result. A small receipt like this is often enough:

{
  "states": ["idle", "sending", "polling", "message-found", "verified"],
  "inbox": "run-1842@example.test",
  "messageId": "msg-73",
  "attempts": 2
}
Enter fullscreen mode Exit fullscreen mode

It is the same useful habit as leaving a reproducible CI receipt for API drift: preserve enough context that someone can understand the run without re-running it.

A practical checklist for React email tests

Before calling the flow complete, check these details:

  1. Does every UI state have one clear meaning?
  2. Does the test adapter own inbox polling and fixture cleanup?
  3. Does each retry get a new request timestamp and fixture identity?
  4. Can the report distinguish delivery timeout from token rejection?
  5. Are message bodies excluded from logs unless a local debug run needs them?
  6. Is the temporary inbox closed after both success and failure?

Also check the small things. A dummy e mail label in a fixture name can confuse a search later, and a tepm mail com typo in an environment note can send a teammate down a wrong path. Keep those values isolated and validate addresses before the test starts.

The goal is not to build a framework for every email test. It is to give a React form a truthful contract. Once the states are explicit, the UI is easier to explain, the TypeScript compiler catches missing cases, and failures point to the layer that actually needs fixing. That makes the next feature a little less fragile, and debugging alot less dramatic.

Top comments (0)