Blog / How to Test Email Verification and Password Reset Flows End-to-End (Without Flaky Tests)
How-To

How to Test Email Verification and Password Reset Flows End-to-End (Without Flaky Tests)

Email verification and password reset tests flake on the inbox, not the clicks. Here's the architecture that makes them deterministic in CI.

Every auth suite has one test the team has quietly agreed to re-run until it passes. It is almost always an email flow: signup verification, or password reset. The clicks are trivial. Submit a form, hit a “check your email” screen, click the link, confirm you are in. What actually breaks is the invisible stretch in the middle, where an asynchronous email has to cross an SMTP server and land somewhere your test can read it.

One asynchronous hop, and your two highest-stakes journeys become your two least reliable tests: signing up, and getting back into a locked account. Here is the architecture that makes them deterministic.

What you’ll learn

  • Why email verification and password reset are the two tests your team keeps quietly re-running
  • The four reasons these tests flake: async delivery, shared inboxes, brittle extraction, and token expiry
  • The deterministic architecture: a queryable inbox, link or code extraction, and polling instead of hard waits
  • Step-by-step flows for both journeys, including token expiry and single-use edge cases
  • How Pie runs the whole flow with an isolated inbox per worker and zero fixed sleeps

Why Email Verification and Password Reset Flake

The flake is not in your app. It is in the handoff. The moment a test clicks “Sign Up,” control leaves the deterministic world of your test runner and enters one you do not own: a mail provider, a relay, a spam filter, an inbox. Four things go wrong out there, and together they are why these two tests dominate most teams’ flaky test reports.

  • Asynchronous delivery. The email does not arrive on a schedule. A test that sleep(3000)s and then reads the inbox is betting the message beats the clock. When a relay lags or CI is loaded, it loses, and the test goes red on an empty inbox for a reason that has nothing to do with your code.
  • Shared inbox pollution. Point every test at one [email protected] and parallel runs start reading each other’s mail. A test grabbing the “latest” verification email intercepts a code meant for another thread. The failure is nondeterministic by construction.
  • Brittle extraction. Once the message lands, the test parses it for the link or code. Hardcode a DOM position or an exact-text regex, and the next template change from your marketing team breaks the test without breaking anything a user sees.
  • Token expiration. Reset links and OTPs expire in minutes by design. When CI backs up and the gap between minting the token and clicking it exceeds that window, the test fails on a security feature working exactly as intended.

None of these are app bugs. They are architecture gaps in the test. Close all four and the flake goes with them.

Three Pieces That Make Email Tests Deterministic

Three pieces close all four of those gaps: an inbox you can query, extraction from the real message, and polling instead of a guessed wait. Replace the mailbox you cannot see into with one you can, and the flow stops being a coin flip.

1. A Programmable Inbox You Can Query

Instead of sending to a real Gmail account or a black-box SMTP server, point your app’s mail transport at a service that gives every test its own disposable inbox and an API to read it: Mailosaur, MailSlurp, or a self-hosted Mailpit (an actively maintained alternative to the now-unmaintained MailHog). Sending stays identical to production. Your code still hands a message to SMTP. Reading becomes a query you control.

2. Link or Code Extraction From the Real Message

Fetch the newest message for that address, parse its HTML body, and pull the href that matches your verification or reset route, or the six-digit code if the flow sends one instead of a link. Navigate to that link, or type that code. Do not try to click inside a rendered email, because no browser automation tool renders an inbox the way a mail client does.

3. Polling Instead of a Fixed Wait

Wait for the specific message to exist, up to a ceiling, rather than pausing for a guessed duration. This is the one piece that actually kills the flake, and it gets the full treatment, with code, further down.

Tired of retrying the same auth tests?

Get email verification and password reset passing end to end, with an isolated inbox per run.

Talk to the team

Testing Email Verification, Step by Step

Put those three pieces together on the signup flow, and email verification falls out as five concrete steps. A new user registers, receives an activation email, clicks the link, lands activated. Run it against a programmable inbox, not a real mailbox:

  1. Provision an isolated address. Ask the inbox service for a fresh address (for example signup-{worker-id}@inbox.testdomain.com). One address per test run means parallel workers never read each other’s mail.
  2. Drive the signup UI. Fill the registration form with that address and submit. Assert you reach the “check your email” screen. This is the only part a traditional automation framework handles well.
  3. Poll for the message. Query the inbox API for a message to that address matching the verification subject, retrying until it arrives or a timeout trips. No fixed sleep.
  4. Extract the link. Parse the HTML body and pull the href matching your verification route (for example /verify?token=). Store the URL.
  5. Navigate and assert. Go to the extracted URL. Assert the account is now active: the success page renders, and a follow-up login or an API check confirms the verified flag flipped.

Verifying a real rendered message is the point. Activation is a funnel step every new user has to clear, so a broken link is lost signups you never see. This flow is where you catch the wrong-environment link (a localhost URL in a staging email) that unit tests and mocks sail right past. It clusters naturally with your other authentication flow tests, and it should be as boring and green as they are.

Testing Password Reset (and Its Edge Cases)

Password reset is email verification plus a security contract, so the happy path is the same shape and the value is in the edge cases. The happy path: request a reset, poll the inbox, extract the reset link, set a new password, and confirm you can log in with the new one and not the old. The edge cases are where reset flows actually fail in production, and where OWASP’s Forgot Password Cheat Sheet puts the real requirement: reset tokens must be single-use and expire after an appropriate period.

Test each of those as an explicit case:

  • Token expiry. Generate a reset link, then advance the token’s issued-at timestamp past the expiry window through a test-only backend hook, and assert the link now returns an expired-token error. Do not wait real minutes for it to lapse. Control the clock.
  • Single-use. Complete a reset once, then replay the exact same link and assert it is rejected. A reusable reset token is a live account-takeover vector.
  • Invalidation on reissue. Request two resets in a row, then assert the first link no longer works once the second is issued.
  • Old password revoked. After a successful reset, assert the previous password fails at login.

The reason time-dependent cases like expiry belong on a backend hook and not a real timer is the same reason a fixed sleep flakes: real time is not yours to control in CI. A test-only endpoint that ages a token, gated so it never ships to production, turns a 15-minute wait into a deterministic assertion. The same principle drives test isolation: reach the state you need directly instead of driving the whole system in real time to get there.

How to Read the Inbox: Four Approaches Compared

Both flows just hinged on the same component: the inbox. How you read it is the one decision that decides whether these tests are reliable. There are four common approaches, and they differ most on what CI cares about: can workers run in parallel, can you reset state, and do you depend on a service you do not control. The honest comparison:

ApproachParallel runsState resetExternal dependencyTests the real email
Real inbox (Gmail API)Shared, rate-limitedNoYes
Mock SMTP transportYesYesNo
Self-hosted (Mailpit)Yes, one instanceYesNo
Hosted inbox API (Mailosaur / MailSlurp)Unlimited, per-addressYesYes (managed)

The mock row is the outlier that fails the one column that matters: it never tests the real message. Between the others, a self-hosted Mailpit is the cheapest way to get a real, queryable inbox for a single CI instance, and its SMTP-plus-HTTP-API design is built exactly for this. Hosted services trade a dependency for per-address isolation that scales to any number of parallel workers. Pick based on how much parallelism your suite actually needs, not on which logo is most familiar.

The Polling Pattern That Kills the Flake

Whichever inbox you picked, one line still decides the outcome: how you wait for the message. The fixed sleep() is the line that makes email tests flaky, and polling is the fix. A sleep encodes a guess about how long delivery takes; when the guess is wrong in either direction the test is either slow or red. Polling waits for the actual condition (the message exists) up to a ceiling, so it returns the instant the mail lands and only fails if it genuinely never arrives.

Every serious automation framework already treats fixed waits as an anti-pattern. Playwright’s assertions auto-wait and retry until the condition is met instead of pausing for a fixed duration. Apply the same rule one layer out, to the inbox:

// Anti-pattern: a fixed guess. Slow when fast, red when slow.
await page.waitForTimeout(3000);
const email = await inbox.latest(address);

// Deterministic: poll for the specific message, with a ceiling.
async function waitForEmail(inbox, address, { timeout = 20000, interval = 500 } = {}) {
  const deadline = Date.now() + timeout;
  while (Date.now() < deadline) {
    const msg = await inbox.search(address, { subject: /verify|reset/i });
    if (msg) return msg;              // returns the instant it arrives
    await new Promise((r) => setTimeout(r, interval));
  }
  throw new Error(`No matching email for ${address} within ${timeout}ms`);
}

The difference is not stylistic. The sleep version couples your test’s pass rate to your mail server’s worst-case latency on a loaded CI runner. The polling version is indifferent to it. This is the single change that moves an email test out of the “retry until green” pile and into the boring-and-reliable pile, which is the whole point of fixing flaky tests rather than retrying around them.

How Pie Runs Email Flow Tests

Everything above is the pattern. Here is how Pie runs it without you wiring any of it.

Pie drives the screen visually instead of by selector, and reads the inbox through a registered script instead of a fixed wait. The same test survives a redesign, and never sleeps. Three phases:

  1. Drive the UI like a human. The agent fills the signup or reset form and confirms the “check your email” state visually. No selectors to maintain.
  2. Read the email through a registered script. A registered inbox action polls the programmable mailbox until the message lands, then returns the link or the code from that email.
  3. Complete and verify. The agent navigates to the link or types the code, then asserts the resulting app state.

Two properties fall out of that design:

  • An isolated inbox per run. Each run uses its own disposable address, so parallel workers never read each other’s mail and there is no shared inbox to reset.
  • No fixed wait anywhere. Because the inbox step is a script and not a sleep, nothing in the flow pauses on a guess.

A test case reads about like this:

#{new_inbox}
[open app]
[submit password reset for the new inbox address]
[verify "check your email" screen appears]
#{wait_for_reset_email}
[navigate to extracted reset link]
[set new password]
[verify login succeeds with new password]
[verify login fails with old password]

The same autonomous testing loop that handles the rest of your app handles the email leg, including the mobile case where the reset link opens through a deep link instead of a browser tab. You describe the flow once; the visual layer absorbs the UI churn that breaks selector-based email tests on every redesign.

Treat the Inbox as Infrastructure

You already control every other dependency in your suite. The database gets seeded and reset. The clock gets frozen. Third-party APIs get a sandbox. The inbox is the one thing most teams still leave to chance, so the inbox is where the retries pile up.

Give it the same treatment: a programmable inbox, extraction from the real message, polling instead of sleeping. Do that and your two flakiest tests turn into two of your most trustworthy, on the exact journeys you cannot afford to ship broken. We built Pie to hand you that out of the box, an isolated inbox per run and no fixed wait to flake on, so you describe the flow once and stop babysitting it.

Make auth the boring part of your suite.

See both auth flows run end to end, without a single fixed sleep.

Book a demo

Frequently Asked Questions

Email flow tests flake because the assertion depends on an asynchronous email arriving in an inbox the test does not control. Most teams paper over that gap with a fixed sleep, then the test passes when the mail server is fast and fails when it is slow. The fix is to poll a programmatic inbox with an API until the message arrives, instead of guessing how long delivery takes.
Mock the SMTP transport in unit tests to confirm your code tried to send. Send real messages to a programmatic inbox (Mailosaur, MailSlurp, Mailpit) in end-to-end tests, because the token you must click lives inside the rendered email body. A mock never generates that link, so a mock-only suite never tests the one thing that actually breaks in production: the reset link.
Fetch the message from the inbox API, then parse the HTML body and pull the href whose URL matches your verification or reset route. Regex works for simple templates; an HTML parser is safer once the template has multiple links. Navigate directly to that extracted URL rather than trying to click inside a rendered email, which no browser automation tool renders as a real inbox.
Test it the same way you test a verification link. Poll the programmable inbox until the message arrives, extract the 6-digit code from the rendered body with a numeric match, and type it into the verification field instead of navigating to a URL. The isolated inbox and the polling stay identical. If you do not need to exercise the email itself, a test-only backend endpoint that returns the current code also works, but it leaves the real message untested.
Generate a reset link, then set the clock or the token's issued-at timestamp past the expiry window through a test-only backend hook and assert the link now shows an expired-token error. For single-use, complete the reset once, then replay the same link and assert it is rejected. Both cases require controlling token state, so a test-only backend endpoint beats waiting in real time.
You can, using Gmail's plus-addressing and the Gmail API, but it does not scale. Real inboxes share state across parallel workers, are rate-limited, depend on an external service you do not control, and cannot be reset between runs. A disposable programmatic inbox gives every worker its own isolated mailbox that resets instantly, which is what CI parallelism needs.
Pie drives the UI like a human (submit the form, land on the check-your-email screen), then calls a registered inbox script that polls a programmable mailbox until the message arrives and returns the extracted link. Pie navigates to that link and verifies the resulting app state. Each run uses an isolated mailbox and starts from a reset, so parallel workers never collide.
Skipping the email entirely. Teams stub the token in the database and jump straight to the reset page, so the test passes while the actual email, the part users touch, ships untested. Broken templates, wrong links, and expired-token copy reach production because the suite never rendered or read a real message.
Adithya Aggarwal
Adithya Aggarwal
CTO & Co-founder at Pie

Eight years building search and delivery systems at Amazon. The kind of scale where flaky tests block billion-dollar releases. Now CTO at Pie, building AI agents that adapt when your UI changes. LinkedIn →