DEV Community

Silviu Technology
Silviu Technology

Posted on

Make Signup Email Tests Less Guessy

Signup email tests often fail in a way that annoys everybody. The UI submission works, the API says success, and then the end-to-end check times out waiting for a message that may still be in flight. A lot of teams call that “email flake,” but most of the time the product is not broken. The test is just asking the wrong question at the wrong moment.

I run into this in both Playwright and broader QA suites. The usual smell is a test that clicks Submit, waits a fixed number of seconds, and then panics when the inbox is empty. That approach feels simple, but it hides useful failure signals. It also makes debugging weird aliases or throwaway addresses like temp gamil com or tempail more confusing than it needs to be.

Why signup email tests fail even when the app is fine

The failure is rarely “no email system exists.” It is more often one of these:

  1. The app accepted the signup, but the mail job is still queued.
  2. The test inbox contains an older message from another run.
  3. The assertion expects the exact subject before the template finishes updating.
  4. The test never stores enough evidence to tell delay from real breakage.

That last one is the biggie. When a failure report only says “expected email not found,” the team learns almost nothing. Was the user created? Did the job enqueue? Did the provider reject the message? Was the inbox reused? If your answer is “rerun it and see,” the workflow is too fuzzy.

This is where I like the mindset behind state-bound reset flows. The point is not just to confirm delivery. The point is to tie the message to the exact state transition your test triggered.

The wait model I use in Playwright

I prefer a two-stage wait instead of one big sleep.

First, I wait for the product event that should cause the email. That might be the signup response, a success banner, or a backend event visible through test logs. Second, I poll the inbox with a deadline and narrow matching rules.

Here is the basic shape:

const startedAt = Date.now();

await page.getByLabel("Email").fill(testAddress);
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText("Check your inbox")).toBeVisible();

const message = await pollInbox({
  inbox: testAddress,
  subjectIncludes: "Verify your account",
  newerThan: startedAt,
  timeoutMs: 45_000,
  intervalMs: 2_000,
});

expect(message.html).toContain("/verify?token=");
Enter fullscreen mode Exit fullscreen mode

Why this helps:

  1. newerThan filters out stale mail from prior runs.
  2. Polling every two seconds gives better feedback than a blind 30-second sleep.
  3. The timeout stays explicit, so the failure tells you how long the system had.

That third point sounds small, but it is realy useful in triage. If the test failed after 45 seconds and the queue normally clears in 5, I start looking for a system issue. If it failed after 8 seconds because someone shortened the timeout, that is a test design issue instead.

A small pattern for collecting better evidence

I want every failure to leave behind enough clues for a human to act. My minimum evidence pack is:

  • the generated inbox address,
  • the timestamp when signup started,
  • the final poll response,
  • any message ids found during polling,
  • a screenshot of the success state,
  • the relevant request or trace id if the app exposes one.

That evidence is often more valuable than another rerun. Google’s testing research has repeatedly pointed at the cost of flaky tests and the value of quick diagnosis; a useful overview is in this Testing Blog post on fighting test flakiness. You do not need Google-scale tooling to apply the lesson. You just need your failure output to be a bit less vague.

This also pairs nicely with invite flows without state drift. If the app treats invite or verification state clearly, your test can assert on a smaller, cleaner surface area.

How I triage failures without rerunning blindly

When a signup email test fails, I go through this order:

  1. Confirm the UI reached the success state.
  2. Check whether the inbox was unique to the run.
  3. Compare the poll deadline with normal delivery time.
  4. Inspect whether a message arrived with the wrong subject or template version.
  5. Only then decide whether to rerun.

This order matters because reruns can hide race conditions. A second pass may “fix” a slow queue, but it teaches the team nothing. I would rather mark the failure as infrastructure-noisy and keep the evidence than pretend the suite is healthier than it is.

One practical tip: separate “no message arrived” from “message arrived but content was wrong.” Those are different defects, owned by different people, and they should not collapse into the same assertion text. Teams get faster when the failure wording is boringly specific, even if the prose is a little imperfect sometmes.

Q&A

Should I use one inbox for a whole test file?

Usually no. Per-test inboxes are easier to reason about, especially when the suite runs in parallel. Shared inboxes save a tiny bit of setup and create a lot of doubt later.

What timeout is reasonable?

Start from observed delivery time in staging, then add a buffer. If most messages land in 3 to 5 seconds, a 45-second timeout is generous without being silly.

When do I treat this as product risk instead of test flake?

When the product event succeeds, inbox isolation is clean, and delivery still misses the deadline often enough to affect releases. At that point the test is doing its job, even if the result is a bit uncomfy.

Top comments (0)