How to Test OTP Emails, Verification Links and Password Resets in Automated Tests

Isometric open envelope with a one-time passcode card and a checkmark badge, representing automated OTP email testing

TL;DR: To test OTP emails in automation, send every message your app triggers to a disposable test inbox, wait for it with a bounded poll, extract the code or link, and feed it back into the same browser session. Then assert the negative paths too: expired codes, reused links and unknown accounts. These flows guard the front door, and Verizon's 2025 Data Breach Investigations Report found that credential abuse was the leading initial attack vector at 22% of breaches. A sign-up or reset flow that only gets checked by hand before a release is a gap in both quality and security.

We see the same pattern in many test suites: login, sign-up and password reset are marked critical, yet the email step is skipped, stubbed or left for a human. This guide shows how to close that gap.

Quick answers

How do you test OTP emails in an automated test?
Route the email to a test inbox your test can read through an API, poll until the message arrives, parse the code from the body with a strict pattern, and type it into the app. Assert the message metadata and the code format before you use it.

What should a password reset email test check?
Check that the email arrives within a time budget, that the sender and subject are correct, and that the reset link points to your real domain over HTTPS. Then confirm the link works once, fails on a second use, and fails after it expires.

Should you mock email in end-to-end tests?
Mock it in unit and component tests, but not in the few journeys that prove a user can sign up, log in and recover an account. Those tests catch broken templates, wrong links and misconfigured senders, and a mock hides all three.

Why are OTP and verification emails so hard to automate?

They cross a boundary your test runner does not control. A background job renders a template, a sending service delivers it, and it lands in a mailbox your test cannot see.

That boundary creates four recurring problems:

  • Timing. Delivery can take one second or forty, so fixed sleeps make tests slow or flaky.
  • Isolation. Parallel runs sharing one mailbox pick up each other's codes.
  • Access. Real mailboxes need credentials and MFA that do not belong in CI.
  • Parsing. A regex written for last quarter's template breaks when the design changes.

Many teams respond by mocking the email service. That suits third-party APIs you do not own, as our post on mocking third-party APIs in E2E tests explains. Transactional email is different: the template, link builder and token logic are your own code, and mocking them proves the button works but not that a user can finish the journey. That is the case for proper end to end email testing on your highest-risk flows.

QA engineer checking a one-time passcode on a smartphone next to an open laptop running tests

What should you assert in an OTP or verification email?

Assert the message itself before you use what is inside it. A test that only extracts a code passes even when the email shows the wrong expiry text or a broken layout.

Message-level checks

  • Sender and reply-to match your production values, not a staging default.
  • Subject matches the expected pattern for the flow and locale.
  • Authentication headers are present. Google's email sender guidelines require all senders to Gmail to set up SPF or DKIM and use TLS, and bulk senders also need DMARC. A missing signature in staging often means a missing signature in production.
  • Arrival time is inside your budget, for example under 60 seconds.

Content checks

  • The code has the expected length and character set, such as six digits.
  • The expiry text in the body matches the server setting.
  • Dynamic values such as first name and account name are filled in, with no raw template tags.
  • Every link and image URL resolves, and images carry alt text.

Flow checks

  • The code or link actually completes the journey.
  • A second use fails.
  • An expired code fails with a clear message.
  • Sign-up verification: Key assertions: Link domain, HTTPS, single use, account becomes active; Common failure we see: Link points to an internal staging host
  • Login OTP: Key assertions: Code format, expiry text, only latest code works; Common failure we see: Older codes still accepted after a resend
  • Password reset: Key assertions: Generic response for unknown emails, link expires, no auto login; Common failure we see: Reset link built from the request Host header

How long should an OTP or reset code stay valid in tests?

Your tests should assert whatever lifetime your product promises, and that lifetime should follow a published standard rather than a guess. Write the expiry as a test, not a comment.

RFC 6238, the TOTP standard used by authenticator apps, recommends a default time step of 30 seconds and at most one extra step for network delay. For codes sent through a separate channel, NIST SP 800-63B says out-of-band authentication must be treated as invalid unless completed within 10 minutes, and each secret may be accepted only once. NIST also states that email shall not be used as an out-of-band authenticator, while noting that email confirmation codes and recovery codes are not authentication processes under that rule. That distinction matters for test design: a sign-up verification email and a login OTP are different controls and deserve different assertions.

You do not want a real 10-minute wait in CI. Shorten the lifetime through test-only configuration, or move the server clock forward with a test hook, then assert both sides of the boundary: the code works just before expiry and fails just after.

Tired of flaky OTP steps? See how ContextQA email testing captures the code and feeds it back into the flow, so the journey continues in one test.

How do you test a password reset email from start to finish?

Treat it as one journey with timed checkpoints. The happy path is the easy half; the security behavior around it is where most bugs hide.

The OWASP Forgot Password Cheat Sheet is the best checklist we know. It recommends a consistent message for existing and non-existent accounts, tokens that are random, single use and expiring, reset URLs that do not rely on the Host header, and no automatic login after the reset. Each of those turns directly into an assertion.

Worked example: password reset with a 15-minute token

Assume a token that lives 15 minutes in production and 20 seconds in test:

  1. Create a fresh user with a unique inbox address for this run, for example reset-<runId>@ your test domain.
  2. Open the forgot password page, submit the address, and record the time.
  3. Assert the on-screen message. Then submit an address that does not exist and assert the exact same message and a similar response time.
  4. Poll the test inbox every 2 seconds for up to 60 seconds. Fail with a clear message if nothing arrives.
  5. Assert sender, subject, SPF or DKIM headers, and that exactly one email arrived for this address.
  6. Extract the reset link. Assert it uses HTTPS and your production domain pattern, not whatever host the test ran against.
  7. Open the link, set a new password, and assert the success message. Assert the user is not logged in automatically.
  8. Log in with the new password. Assert the old password fails.
  9. Open the same reset link again and assert it is rejected.
  10. Request a second reset, wait 25 seconds, open that link, and assert it is rejected as expired.

Steps 1 to 7 in Playwright, where the helpers wrap your test inbox:

import { test, expect } from '@playwright/test';

import { createUser, waitForEmail } from './helpers';

test('user can reset password from emailed link', async ({ page }) => {

const user = await createUser(); // unique inbox per run

await page.goto('/forgot-password');

await page.getByLabel('Email').fill(user.email);

await page.getByRole('button', { name: 'Send reset link' }).click();

const mail = await waitForEmail(user.email, { subject: /reset your password/i, timeoutMs: 60_000 });

expect(mail.from).toBe('no-reply@yourapp.com');

const link = mail.html.match(/href="(https:\/\/app\.yourapp\.com\/reset\?token=[^"]+)"/)?.[1];

expect(link).toBeTruthy();

await page.goto(link!);

await page.getByLabel('New password').fill('Corr3ct-Horse-Battery!');

await page.getByRole('button', { name: 'Update password' }).click();

await expect(page.getByText('Password updated')).toBeVisible();

});

The regex pins the domain, so a link builder that reads the Host header fails this test.

How do you stop email tests from being flaky?

Most flakiness comes from shared state and fixed waits, not from email itself. Fix those two and email steps become as stable as any UI step.

  • One inbox per test. Generate the address from the run ID and test name. Never reuse a mailbox across parallel workers.
  • Poll with a deadline. Replace sleep(30) with a loop that checks every one to two seconds and fails after a set budget. Fast runs stay fast.
  • Filter tightly. Match on recipient, subject and a timestamp after the trigger, so a late email from a previous run cannot satisfy the wait.
  • Parse structure, not styling. Extract from a stable attribute such as data-otp where possible.
  • Log the raw message on failure. Attach the HTML and headers to the report so a vague timeout becomes a quick fix.
  • Respect rate limits. If your app limits resends per account, a retry loop can lock the account. Use fresh users instead.

If the forms around the email step change often, AI-based self-healing can keep the clicks and fills stable while you keep the email assertions strict.

Where do email tests fit in a CI/CD pipeline?

Run a small set on every pull request and the fuller set on merge or nightly. Email journeys are slower than API tests, so choose which ones gate a release.

  • Pull request: What to run: Sign-up verification and login OTP happy paths; Why: Catches template and link regressions early
  • Merge to main: What to run: Full password reset journey with negative checks; Why: Protects the account recovery path
  • Nightly: What to run: Rendering across clients, link and image checks, locale variants; Why: Slower, broader coverage

The key rule: a broken send must fail the pipeline, because a warning nobody reads is not a test. This is also where email checks become part of continuous testing rather than a separate manual task before each release.

Tester reading a verification email on a smartphone while an automated test runs on her laptop

Should you build your own email test harness or use a platform?

Build it if you have a few flows and an engineer who enjoys mail plumbing. Use a platform when email journeys span several products, teams or locales and the harness becomes a maintenance job of its own. A homegrown setup works, but the cost shows up later: client rendering checks, link validation, audit history and parsers that drift as templates change.

ContextQA takes a different approach. It captures every email your app triggers, from the UI or an API, into a reusable test inbox with no external mailbox or credentials. It validates subject, sender, headers, attachments, body, dynamic values and HTML structure, checks responsive rendering across clients in dark and light modes, confirms every link and image URL is reachable, and flags missing alt text. It extracts OTP codes or verification links and feeds them back into the flow so sign-up, login and password reset journeys continue in one test. Runs plug into CI/CD so broken sends fail the pipeline, each run keeps an audit trail, and the platform is SOC 2 and GDPR aligned.

Want to see a password reset journey tested from click to new login? Book a demo and bring your trickiest email flow.

Bottom line

Email steps are part of your most important user journeys, so test them like any other step: isolated inboxes, bounded waits, strict parsing and assertions on both the happy path and the failure paths. Expiry, single use and consistent responses are behaviors worth proving on every release.

Start with password reset and make it fail the pipeline when anything is off. Sign-up verification and login OTP follow the same pattern.

Sources

  1. 2025 Data Breach Investigations Report press release, Verizon, https://www.verizon.com/about/news/2025-data-breach-investigations-report. Cited for credential abuse being the leading initial attack vector at 22% of breaches.
  2. NIST Special Publication 800-63B, Digital Identity Guidelines: Authentication and Authenticator Management, National Institute of Standards and Technology, https://pages.nist.gov/800-63-4/sp800-63b.html. Cited for the 10-minute out-of-band completion window, single acceptance of secrets, and the rule that email shall not be used for out-of-band authentication.
  3. RFC 6238, TOTP: Time-Based One-Time Password Algorithm, IETF via RFC Editor, https://www.rfc-editor.org/rfc/rfc6238. Cited for the recommended 30-second time step and one-step network delay allowance.
  4. Forgot Password Cheat Sheet, OWASP Cheat Sheet Series, https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html. Cited for consistent responses, single-use expiring tokens, Host header guidance, no automatic login and rate limiting.
  5. Email sender guidelines, Google Workspace Admin Help, https://support.google.com/a/answer/81126. Cited for SPF or DKIM, TLS and DMARC requirements for senders to Gmail.
Share the Post:

Author

Deep Barot

CEO @ ContextQA | Agentic AI for Software Testing | Context-aware Testing

Deep Barot is the Founder and CEO of ContextQA, the only AI testing platform that understands context. He brings decades of experience across DevOps, full-stack engineering, cloud systems, and large-scale platform development.
AI Insights
Real User Intelligence Platform

Turn live sessions into test coverage. No prompts, no manual design - just pointed at your URL and generating suites within minutes.

Minutes
From URL to generated test cases
Zero
Prompts or manual test design needed
40%+
Average coverage increase after first run
100%
Based on real user behavior, not guesses

Frequently Asked Questions

You can, but we do not recommend it. Real accounts need stored credentials, may trigger their own security checks, and are hard to isolate across parallel runs.
Use the same structure: a per-test receiver, a bounded poll, strict parsing and expiry checks. Keep SMS and email as separate tests, since they usually run through different providers.
No. Rendering across clients and dark mode is slower, so a nightly run is usually enough. On every merge, check that the code or link is present in the HTML part.
Ask the template owners to add a stable hook, such as a data attribute around the code. It is a one-line change that removes a whole class of parsing failures.
Trigger two sends and extract both codes. Submit the first and assert it is rejected, then submit the second and assert it succeeds.
Related Blogs