Email verification tests often fail for a reason that has nothing to do with email delivery. The React screen, the browser test, and the backend are not agreeing on which verification attempt they are talking about.
That shows up as a familiar CI failure: the test submits an address, changes the field, retries, and then asserts against a message from the first request. The application may be correct for the latest action, but the test is observing an older one. It makes a good suite feel random, and random feedback slows every feature down.
Why email tests become flaky
An email verification flow usually has several asynchronous boundaries:
- React validates the field and enables a button.
- The browser sends a request to the API.
- The API creates a verification attempt.
- A worker sends a message.
- The test reads the inbox and checks the link.
If those steps share only an email string, ownership is fuzzy. Two attempts for alex@example.test look identical even when one is stale. A fake emails generator or a generate disposable email helper can provide unique addresses, but unique data alone does not solve duplicate clicks, retries, or a late response repainting the UI.
The UI dont need to know every detail of the mail worker. It does need a stable identifier for the user action it currently represents.
Give every verification attempt an owner
I like treating an attempt as a small contract:
{
attemptId: "signup-42-2",
email: "alex@example.test",
status: "pending"
}
The attemptId is generated when the user submits, not when a response happens to arrive. React stores it, the API echoes it, and the test uses it when checking the result. If a second submit occurs, it gets a new id. The old request can still finish, but it no longer owns the visible state.
This is a simple version of request ownership. It works with a normal JavaScript fetch, and it gives logs something more useful than a repeated email address. When a failure appears, I can ask, “Which attempt did this message belong to?” instead of guessing from timestamps.
The same boundary is useful for test infrastructure. Replayable email checks make a failed message easier to inspect after the browser step is over. For parallel environments, namespace isolation ideas apply to inboxes too: each test should have a scope it can safely own and clean up.
A small React and JavaScript implementation
Here is a deliberately boring hook pattern. The important part is that the response must match the current attempt before it updates the screen.
import { useRef, useState } from "react";
export function useEmailVerification() {
const sequence = useRef(0);
const currentAttempt = useRef(null);
const [state, setState] = useState({ attemptId: null, status: "idle" });
async function verify(email) {
const attemptId = `verify-${++sequence.current}`;
currentAttempt.current = attemptId;
setState({ attemptId, status: "pending" });
try {
const response = await fetch("/api/email-verification", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, attemptId }),
});
const result = await response.json();
if (result.attemptId !== currentAttempt.current) {
return;
}
setState({ attemptId, status: result.status });
} catch (error) {
setState({ attemptId, status: "error", error: error.message });
}
}
return { state, verify };
}
In production, I would compare the returned id with the current state more explicitly and use AbortController where cancellation matters. The example is about the contract, not a full data-fetching library. Also, keep the id in the API response; a client-side counter by itself doesnt prove which server-side attempt produced a message.
On the server, persist the attempt id with the verification record. A unique constraint on the id, plus an expiry time, makes retries more predictable. If a request is repeated, return the existing attempt rather than silently creating a second one. That small choice makes a bit more clear what the browser should assert.
Make Playwright fixtures follow the same contract
The browser test should capture the attempt id or a test-owned email address, then wait for the matching result. Avoid a broad assertion such as “the inbox contains a verification email.” It may pass because another test sent one.
test("verifies the latest signup attempt", async ({ page, request }) => {
const email = `run-${crypto.randomUUID()}@example.test`;
await page.getByLabel("Email").fill(email);
const responsePromise = page.waitForResponse("**/api/email-verification");
await page.getByRole("button", { name: "Send code" }).click();
const response = await responsePromise;
const { attemptId } = await response.json();
await expect(page.getByText("Check your email")).toBeVisible();
const message = await waitForVerificationMessage(request, { email, attemptId });
expect(message.attemptId).toBe(attemptId);
});
The fixture can poll with a deadline, but it should filter by both the test-owned address and the attempt id. A timeout then means “this attempt did not produce its message,” which is actionable. It does not mean “some email was not found somewhere.”
What to check before shipping
I use this short checklist when a verification suite starts getting noisy:
- Does every submit create or reuse a clearly identified attempt?
- Can a late response update the current React state?
- Does the API return the identifier that the worker stores?
- Does each test own a unique address or inbox namespace?
- Can a retry be distinguished from a new user action?
- Does cleanup happen after both success and failure?
If the flow supports a tempail mail test address or another disposable address, keep that choice in the fixture configuration. Do not let test data policy leak into assertions about request ownership.
Final takeaway
Stable email verification tests are mostly about stable intent. Give each attempt an owner, carry that owner through React, JavaScript API code, and the mail fixture, then assert on the owned result. The implementation is small, but it removes a whole class of flaky failures before they become somebody's morning CI mystery.
Top comments (0)