Your password-reset test finds an email, opens a URL, and sees a success message. It can still miss a broken recovery flow: the URL may belong to another message, an existing browser session may hide a login failure, or the old password may still work.
To test a password-reset email end to end with Playwright, request recovery for a known account, receive its actual email in an isolated inbox, select the reset URL, submit a new password, and verify authentication in fresh browser contexts. Then revisit the consumed link and check that it cannot change the password again. The recipe below provides four complete TypeScript files to adapt to your staging application.
Decide what a passing password-reset test proves
This example crosses the real email-reception boundary. Your staging application sends the reset email through its configured mail provider; MailHulk receives it and exposes the parsed message through its API. MailHulk is receive-only.
| Check | Evidence from the journey | A false positive to avoid |
|---|---|---|
| Account exists and can authenticate | The original password signs in before recovery | An unknown address gets the same generic confirmation |
| The expected email arrives | The attempt's inbox receives a new message with the configured subject | An old message or another test supplies the link |
| The link reaches the intended reset route | Exactly one distinct URL matches the configured origin, path, and token shape | The first link is a logo, footer, or help page |
| The password actually changes | Fresh sessions reject the old password and accept the new one for this account | A success heading appears without updating the credential |
| The consumed link cannot change it again | Replay is rejected, a third password fails, and the first new password still works | A replay error appears after a second password change has already occurred |
A local SMTP catcher is a reasonable choice when your intended boundary ends at the application's SMTP handoff. Mocks and existing test hooks can cover reset-token rules without waiting for real mail. Keep a focused received-email journey when you need to exercise reception and browser recovery together; it does not establish inbox placement across providers, rendering compatibility, or a security certification.
Prepare one recoverable staging account per attempt
Start with an existing Playwright TypeScript project. Install @playwright/test and its browser if they are not already present. The example uses Playwright's Node-side HTTP client, so it needs no additional email SDK.
npm install --save-dev @playwright/test
npx playwright install chromium
Set these values in your local environment or CI configuration. Store both credentials in the CI secret store; neither belongs in a browser bundle.
| Variable | Purpose |
|---|---|
APP_URL | Your staging application origin, such as https://staging.example.test |
MAILHULK_URL | The MailHulk API origin, without a trailing /v1 |
MAILHULK_API_KEY | A project-scoped key used only by the Node fixture |
RESET_SUBJECT | The exact subject of your reset email |
APP_TEST_TOKEN | Your own staging user-factory credential, if your existing setup uses one |
Application assumptions: the account has a verified email address and password login enabled; recovery sends one reset email; the link opens at /reset-password?token=… on APP_URL; and a different browser session may use it. The selectors, routes, headings, error text, password format, and factory contract below are examples. Replace them with your application's observable behavior. An authentication service on another origin, a token embedded in the path, or a session-bound recovery flow needs a deliberate adapter.
The fixture assumes an existing staging user factory that creates a verified account without sending welcome or verification mail. Its illustrative contract is POST /__e2e/users with email and password, returning 201 and {"data":{"id":"…"}}, then DELETE /__e2e/users/{id} returning 204 or 404. These are your application's example routes, not MailHulk endpoints. Replace that adapter with an existing seed command, factory, or signup fixture. Do not add these routes to a production application just to copy the recipe.
If account creation or the initial login sends email, finish and consume that setup journey before taking the recovery baseline, and account for delayed setup notifications. The default recipe assumes neither produces additional mail. A new cursor alone cannot distinguish a late welcome or new-device email from a reset email. Give test accounts an independent expiry or cleanup policy, including creations whose responses are lost.
Set the browser and email wait budgets
Save the first file as playwright.config.ts. The test budget includes a 60-second arrival wait, multiple browser contexts, and the login checks. The separate fixture budgets cover allocation and teardown. These values are execution limits, not delivery-speed promises; see Playwright's timeout documentation.
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 180_000,
expect: { timeout: 10_000 },
use: {
actionTimeout: 10_000,
navigationTimeout: 15_000,
trace: 'off',
screenshot: 'off',
video: 'off',
},
});
Keep this email project separate from projects that load saved authentication state. The manually created contexts in the spec start without saved credentials. Playwright describes the distinction between isolated browser contexts and reusing authentication state.
Trace, screenshots, and video are disabled here because the browser handles passwords and bearer reset URLs. Apply the same policy to project overrides, custom reporters, network logs, and external monitoring. Even a failing assertion or browser error may expose a URL or entered value; keep reports private and redact sensitive details before sharing them.
Create an inbox and keep early arrivals eligible
Save tests/fixtures/mailbox.ts. Each test attempt creates an inbox with a generated address, and each retry gets a new one. The fixture uses a standalone API request context to keep the project key outside browser requests. mark() records the message baseline before the reset request. receive() then supplies that explicit cursor to the bounded wait.
import { randomUUID } from 'node:crypto';
import { test as base, expect, request } from '@playwright/test';
export function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before running this journey`);
return value;
}
type ReceivedMail = { id: string; subject: string | null; links: string[] };
export type Mailbox = {
address: string;
diagnostic: Record<string, unknown>;
mark(): Promise<string>;
receive(after: string, subject: string): Promise<ReceivedMail>;
};
export const test = base.extend<{ mailbox: Mailbox }>({
mailbox: [async ({}, use, testInfo) => {
const api = await request.newContext({
baseURL: required('MAILHULK_URL'),
extraHTTPHeaders: {
Authorization: `Bearer ${required('MAILHULK_API_KEY')}`,
},
timeout: 10_000,
maxRedirects: 0,
});
let id: string | undefined;
const diagnostic: Record<string, unknown> = { phase: 'create' };
try {
const created = await api.post('/v1/inboxes', {
headers: { 'Idempotency-Key': randomUUID() },
data: { name: `pw-reset-attempt-${testInfo.retry}`, ttl_seconds: 900 },
});
if (created.status() !== 201) {
throw new Error(`Inbox creation failed: HTTP ${created.status()}`);
}
const { data: inbox } = await created.json();
id = inbox.id;
diagnostic.inboxId = id;
const latest = `/v1/inboxes/${id}/messages/latest`;
await use({
address: inbox.address,
diagnostic,
async mark() {
diagnostic.phase = 'baseline';
const result = await api.get(latest, { params: { view: 'summary' } });
diagnostic.httpStatus = result.status();
if (result.status() === 204) return '00000000000000000000000000';
if (result.status() !== 200) {
throw new Error(`Baseline lookup failed: HTTP ${result.status()}`);
}
const { data } = await result.json();
return data.id as string;
},
async receive(after, subject) {
Object.assign(diagnostic, { phase: 'wait', after });
const result = await api.get(latest, {
params: { after, wait: 60, view: 'full' },
timeout: 65_000,
});
const status = result.status();
const degraded = result.headers()['x-mailhulk-wait-degraded'];
Object.assign(diagnostic, {
httpStatus: status,
requestId: result.headers()['x-request-id'],
degraded: degraded ?? null,
});
if (status === 204) {
throw new Error(degraded
? 'Email wait skipped because wait capacity was reached'
: 'No eligible reset email arrived within the 60-second wait');
}
if (status !== 200) {
throw new Error(`Email lookup failed: HTTP ${status}`);
}
const { data }: { data: ReceivedMail } = await result.json();
Object.assign(diagnostic, {
phase: 'select-link', messageId: data.id,
subjectMatches: data.subject === subject,
linkCount: data.links.length,
});
if (data.subject !== subject) {
throw new Error('First eligible email has an unexpected subject');
}
return data;
},
});
} finally {
const failed = testInfo.status !== testInfo.expectedStatus;
try {
if (id) {
const removed = await api.delete(`/v1/inboxes/${id}`);
if (![204, 404].includes(removed.status())) {
throw new Error('Inbox deletion failed');
}
}
} catch {
testInfo.annotations.push({
type: 'cleanup', description: 'Inbox deletion failed; check expiry',
});
if (!failed) throw new Error('Inbox cleanup failed after test');
} finally {
if (failed) {
testInfo.attachments.push({
name: 'reset-diagnostic', contentType: 'application/json',
body: Buffer.from(JSON.stringify({
...diagnostic, retry: testInfo.retry,
}, null, 2)),
});
}
await api.dispose();
}
}
}, { timeout: 30_000 }],
});
export { expect };
The MailHulk API reference defines the cursor behavior: a wait without after establishes its baseline at request time. A reset message that arrives between the button click and the wait call can become that baseline. Capturing the cursor before clicking keeps that early arrival eligible. The all-zero ULID is the lower bound for an empty inbox.
The baseline lookup requests view=summary; the receive call requests view=full because the full message contains links, while the summary provides links_count. The example holds the returned message in Node memory and attaches neither its body nor its links to the diagnostic.
The helper deliberately fails on an unexpected subject. It does not skip through multiple messages, correlate resends, or select a reset email from an arbitrary shared inbox. For a trigger that sends several emails, implement an explicit matching rule with one deadline. For the ownership trade-off, read shared versus isolated inboxes in parallel CI.
The requested 900-second inbox TTL backs up deletion when the test process cannot finish. Check the effective expires_at; configured limits may constrain it. Message retention is a separate policy. The fixture attempts deletion on success and failure, preserves an existing test failure when cleanup also fails, and reports cleanup failure after an otherwise successful test.
Give the application account its own fixture
Save tests/fixtures/user.ts and replace its two example factory calls with your existing staging setup. It depends on mailbox, so account setup receives this attempt's address. Teardown removes the application account before the mailbox fixture deletes its inbox. This uses Playwright fixture dependencies and teardown.
import { randomUUID } from 'node:crypto';
import { request } from '@playwright/test';
import { test as base, required } from './mailbox';
// EXAMPLE application adapter. Replace with your staging user factory.
// These /__e2e routes are not MailHulk endpoints.
type ResetUser = { email: string; oldPassword: string };
export const test = base.extend<{ resetUser: ResetUser }>({
resetUser: [async ({ mailbox }, use, testInfo) => {
const api = await request.newContext({
baseURL: required('APP_URL'),
extraHTTPHeaders: {
Authorization: `Bearer ${required('APP_TEST_TOKEN')}`,
},
timeout: 10_000,
maxRedirects: 0,
});
let id: string | undefined;
const oldPassword = `Old-${randomUUID()}!`;
try {
mailbox.diagnostic.phase = 'seed-user';
const result = await api.post('/__e2e/users', {
data: { email: mailbox.address, password: oldPassword },
});
if (result.status() !== 201) {
throw new Error(`Staging user creation failed: HTTP ${result.status()}`);
}
const { data } = await result.json();
id = String(data.id);
await use({ email: mailbox.address, oldPassword });
} finally {
const failed = testInfo.status !== testInfo.expectedStatus;
try {
if (id) {
const removed = await api.delete(`/__e2e/users/${encodeURIComponent(id)}`);
if (![204, 404].includes(removed.status())) {
throw new Error('Staging user deletion failed');
}
}
} catch {
testInfo.annotations.push({
type: 'cleanup', description: 'Staging user deletion failed',
});
if (!failed) throw new Error('Staging user cleanup failed after test');
} finally {
await api.dispose();
}
}
}, { timeout: 30_000 }],
});
export { expect, required } from './mailbox';
A fresh inbox does not make a reused application account independent. Password changes, lockouts, resend limits, and active sessions are account state. Allocate both resources per attempt so a concurrent worker or retry cannot change the password underneath this test. Removing an inbox does not remove the application account.
Select the reset URL before navigating
An email can contain a logo link, documentation, support, and the reset action. The spec below deduplicates identical URL strings and requires exactly one URL with the configured origin, the exact reset path, no embedded username or password, and exactly one nonempty token query parameter.
This is a selector for this application's URL shape. It does not prove token authenticity, expiry, or recipient binding; the application validates those when it handles the reset. A different origin or an unexpected tracking wrapper causes the selector to fail. Adapt the allowlist to your actual authentication architecture instead of following the first external URL. Do not print the rejected candidates: they can contain bearer tokens.
Run the complete password-reset journey
Save tests/password-reset.spec.ts. The login precondition catches a broken factory before recovery begins. Each subsequent authentication check gets a new context. The success assertion checks both the dashboard and the signed-in email, so the test proves which account authenticated.
import { randomUUID } from 'node:crypto';
import type { Browser, Page } from '@playwright/test';
import { test, expect, required } from './fixtures/user';
const app = new URL(required('APP_URL'));
const location = (path: string) => new URL(path, app).href;
function resetLink(links: string[]): string {
const matches = [...new Set(links)].filter(value => {
try {
const url = new URL(value);
return url.origin === app.origin && url.pathname === '/reset-password'
&& !url.username && !url.password
&& url.searchParams.getAll('token').length === 1
&& Boolean(url.searchParams.get('token'));
} catch {
return false;
}
});
if (matches.length !== 1) {
throw new Error('Expected one distinct reset URL on the allowed origin and path');
}
return matches[0];
}
async function withCleanPage(
browser: Browser, run: (page: Page) => Promise<void>,
) {
const context = await browser.newContext();
context.setDefaultTimeout(10_000);
context.setDefaultNavigationTimeout(15_000);
try {
await run(await context.newPage());
} finally {
await context.close();
}
}
async function login(page: Page, email: string, password: string) {
await page.goto(location('/login'));
await page.getByLabel('Email', { exact: true }).fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
}
async function setPassword(page: Page, password: string) {
await page.getByLabel('New password', { exact: true }).fill(password);
await page.getByLabel('Confirm password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Reset password', exact: true }).click();
}
test('received reset link changes the password once', async ({
page, mailbox, resetUser, browser,
}) => {
const newPassword = `New-${randomUUID()}!`;
let link = '';
await test.step('prove the seeded account can sign in', async () => {
mailbox.diagnostic.phase = 'precondition';
await withCleanPage(browser, async clean => {
await login(clean, resetUser.email, resetUser.oldPassword);
await expect(clean.getByRole('heading', { name: 'Dashboard', exact: true }))
.toBeVisible();
await expect(clean.getByTestId('signed-in-email')).toHaveText(resetUser.email);
});
});
await test.step('request and select the received reset link', async () => {
await page.goto(location('/forgot-password'));
await page.getByLabel('Email', { exact: true }).fill(resetUser.email);
const after = await mailbox.mark();
mailbox.diagnostic.phase = 'trigger-reset';
await page.getByRole('button', { name: 'Send reset link', exact: true }).click();
const message = await mailbox.receive(after, required('RESET_SUBJECT'));
link = resetLink(message.links);
});
await test.step('change the password using the received link', async () => {
mailbox.diagnostic.phase = 'submit-reset';
await withCleanPage(browser, async clean => {
await clean.goto(link);
await expect(clean.getByRole('heading', { name: 'Reset password', exact: true }))
.toBeVisible();
await setPassword(clean, newPassword);
await expect(clean.getByRole('heading', { name: 'Password updated', exact: true }))
.toBeVisible();
});
});
await test.step('reject the old password and accept the new one', async () => {
mailbox.diagnostic.phase = 'verify-login';
await withCleanPage(browser, async clean => {
await login(clean, resetUser.email, resetUser.oldPassword);
await expect(clean.getByRole('alert')).toHaveText('Invalid email or password');
await expect(clean.getByTestId('signed-in-email')).toBeHidden();
});
await withCleanPage(browser, async clean => {
await login(clean, resetUser.email, newPassword);
await expect(clean.getByRole('heading', { name: 'Dashboard', exact: true }))
.toBeVisible();
await expect(clean.getByTestId('signed-in-email')).toHaveText(resetUser.email);
});
});
await test.step('refuse a second change through the consumed link', async () => {
mailbox.diagnostic.phase = 'verify-replay';
const replayPassword = `Replay-${randomUUID()}!`;
await withCleanPage(browser, async clean => {
await clean.goto(link);
const invalid = clean.getByRole('alert').filter({
hasText: 'Reset link is invalid or expired',
});
const form = clean.getByRole('button', { name: 'Reset password', exact: true });
await expect(invalid.or(form).first()).toBeVisible();
// Some apps reject on GET; others validate when the form is submitted.
if (!await invalid.isVisible()) await setPassword(clean, replayPassword);
await expect(invalid).toBeVisible();
});
await withCleanPage(browser, async clean => {
await login(clean, resetUser.email, replayPassword);
await expect(clean.getByRole('alert')).toHaveText('Invalid email or password');
});
await withCleanPage(browser, async clean => {
await login(clean, resetUser.email, newPassword);
await expect(clean.getByRole('heading', { name: 'Dashboard', exact: true }))
.toBeVisible();
await expect(clean.getByTestId('signed-in-email')).toHaveText(resetUser.email);
});
});
});
The replay step allows two application designs: rejection when opening the consumed URL, or rejection when submitting its reset form. It waits for either state using Playwright's locator union. If the form is shown, it submits a third password. A fresh login must reject that third value, and another fresh login must still accept the first new password. An error banner alone would miss an application that changes the password and then renders an error.
Complete replay within the configured token-validity window. If the token expires naturally during the scenario, a rejection no longer isolates consumption from expiry. Use a known test lifetime long enough for this flow, and test expiration separately. This is a sequential replay check; simultaneous redemption needs its own backend concurrency test.
The old password succeeds before the reset and fails afterward. That comparison makes the negative check meaningful. Still adapt the assertions for your login policy: a lockout, a network failure, or the wrong account should not count as the expected credential rejection. This example assumes these few deliberate failures do not trigger a lockout.
npx playwright test tests/password-reset.spec.ts --workers=1
npx playwright test tests/password-reset.spec.ts --repeat-each=2 --workers=2
Run once to verify the selectors and factory contract, then use the repeated parallel run to check resource ownership. Configure the same application and API environment for both commands. Every attempt should have a different inbox and account. Check deletion after an intentionally failed assertion before expanding the suite.
Validation boundary, November 1, 2026: the code was checked against Playwright 1.62.1's types and MailHulk's current OpenAPI contract. Application routes and selectors are illustrative. No live staging account, password reset, or external email delivery was exercised for this article.
Add expiry, resend, and session cases deliberately
The core journey covers recovery and consumed-link behavior. Add these as independent cases with a fresh account so the assertions do not depend on an earlier test's result.
| Case | Setup and assertion | Boundary to make explicit |
|---|---|---|
| Expired link | Expire the token through an existing backend clock or fixture; submit it and verify the credential remains unchanged | Advancing a browser clock does not advance the backend token clock |
| Resend | Request two links sequentially; assert which may change the password according to the documented policy | A subject and cursor do not correlate delayed messages from two requests |
| Wrong account | Use two synthetic accounts and verify a token cannot update the other identity | The application's token-to-account binding is the oracle |
| Unknown address | Check the public response against the application's documented recovery behavior | A confirmation alone is not proof that email was sent |
| Existing sessions | Keep a previously authenticated context, reset elsewhere, then check its next protected request | Session revocation policy differs from accepting a new password |
| Password validation | Submit an invalid new password, then a valid one, and verify the chosen token-consumption policy | A rejected password must not be confused with an expired reset token |
The OWASP Forgot Password Cheat Sheet recommends reset tokens that expire and become invalid after use, consistent public recovery responses, and deliberate session handling. Use those recommendations alongside your application's approved requirements. A passing browser recipe covers the paths it exercises; backend tests should own exhaustive token, abuse, and concurrency rules.
Avoid a long sleep to simulate expiry. Existing backend time controls or seeded expired tokens give that case a bounded setup. Keep a separate focused test for actual expiry timing if that timing is part of your contract.
Diagnose the stage that failed
| Stage | Check next |
|---|---|
| Account setup or initial login | Factory contract, verified status, password policy, login selectors, and staging availability |
| Inbox creation or baseline | HTTP status, API origin, project key, inbox limits, and effective expiry |
| Normal empty wait | Application send attempt, intended recipient, mail queue, and provider response; the timeout alone does not locate the break |
| Degraded wait | Wait capacity and your bounded backoff policy; avoid treating an immediate response as a 60-second delivery timeout |
| Subject or URL selection | Template changes, setup mail, multiple sends, authentication origin, tracking wrappers, and reset route |
| Reset submission | Token validity, session binding, new-password validation, and application state |
| Login or replay | Account identity, credential persistence, token consumption, and lockout behavior |
| Cleanup | Both independent resources: the staging account and the MailHulk inbox |
The failure attachment includes phase, cursor, internal IDs, HTTP status, request ID, and selection counts. It excludes passwords, API keys, message bodies, and reset URLs. Keep that evidence private. Inspect the actual captured message only in an authorized environment when a selection failure needs it.
For the underlying wait model, read how bounded email waits avoid fixed sleeps. The same ownership and freshness rules apply to reset links, even though the browser action differs.
Adapt one reset journey first
Copy the four files, replace the application adapter and selectors, and run one recovery journey. Confirm the old credential stops working, the new one authenticates the intended account, and replay cannot replace it. Then check concurrent execution and teardown after failure.
MailHulk is in preview, with private-beta rollout still being prepared. Bring one password-reset journey for evaluation if you want to try this pattern with a receive-only inbox.
For the shared setup and a choice of OTP, magic-link, and reset journeys, read the Playwright email-testing guide.