GUIDE

Testing password reset, end to end.

Password reset is the flow attackers probe first and test suites cover least — usually one happy-path test, often none, because the token lives inside an email. That's backwards: reset is where the security regressions hide, and the edge cases are exactly the assertions worth automating. Here's the flake-free happy path, and the five tests around it that matter more.

Why reset tests flake

1. Fixed sleeps. The same disease as every email flow: waitForTimeout(5000) fails red when mail takes six seconds and burns four when it takes one. Nothing reset-specific — just the baseline mistake to clear first.

2. The shared test account. Reset has a poison the other flows don't: the test mutates a credential. Run a reset against the shared qa@company.com account and every other test that logs in with its old password starts failing — but only when the reset test runs first, so the suite breaks by run order and nobody can reproduce it locally. Inbox-per-test isn't enough here; you need account-per-test, and a fresh inbox is what makes that cheap — every test gets a private address to register its own throwaway user with.

3. Scraping for hrefs. A reset email carries the reset link, a "didn't request this?" link, a support link, and whatever the tracking wrapper did to all three. "The reset link" is a classification problem, not a string search — the magic-link guide dissects this failure in full; everything it says applies here with kind: 'reset' instead of verify.

The happy path, properly

A fresh inbox, a throwaway user registered on it, a long-poll instead of a sleep, and the link read as a typed, classified field. The assertion at the end is the whole point: the new credential works and the old one is dead.

reset.spec.ts · Playwright
test('password reset issues a working credential', async ({ page }) => {
  const inbox = await mail.createInbox({ ttlSeconds: 900 });
  await seedUser({ email: inbox.address, password: 'old-pw-1' }); // your app's test hook

  await page.goto('/forgot-password');
  await page.fill('#email', inbox.address);
  await page.click('text=Send reset link');

  const link = await inbox.waitForLink({ kind: 'reset', timeout: 30_000 });
  await page.goto(link.url);
  await page.fill('#new-password', 'new-pw-2');
  await page.click('text=Set password');

  await expect(login(page, inbox.address, 'new-pw-2')).resolves.toBeTruthy();
  await expect(login(page, inbox.address, 'old-pw-1')).rejects.toThrow(); // the old credential must die
});

The timeout is a ceiling, not a duration — the wait resolves the moment the email lands. The same shape works from Cypress through a task bridge and pytest through a fixture; API-level suites without a browser can even do the click server-side with follow_link(kind="reset"), as the magic-link guide shows.

The security regressions worth a test each

The test almost nobody writes: enumeration

The forgot-password form is the classic account-enumeration oracle: if "we sent you a link" and "no account with that address" render differently, anyone can probe which emails have accounts. The fix is well known — same response either way — but it regresses easily, and it's testable end to end: ask for a reset on an address that has no account, assert the page says the same thing, and assert that no email actually arrives.

test_reset.py · pytest
stranger = mf.create_inbox(ttl_seconds=900)  # real address, never registered

resp = app.post("/forgot-password", data={"email": stranger.address})
assert "If that address has an account" in resp.text  # same copy as the real case

with pytest.raises(MailFixtureTimeout):
    stranger.wait_for_message(timeout=5)  # silence IS the assertion

Be honest about what the negative half proves: five seconds of silence, not eternal absence. That bounded claim is still the strongest one a test can make about "no email was sent" — and it's exactly enough to catch the refactor that wires the unknown-address branch back into the mailer.

NOTE MailFixture itself has no password to reset — sign-in is magic-link — but the no-reveal rule is one we live by: our own login form answers identically whether or not an address has an account.

Local dev vs CI

While you're styling the reset email, a local SMTP-capture tool like Mailpit gives instant visual feedback — keep it. The CI suite is a different job: receiving on real domains through your real provider is the only way a test catches the staging config that still points at the sandbox. The split is laid out in MailFixture vs. Mailpit.

Related flows, same three mistakes: codes instead of links, login links instead of reset links, and the whole thing from inside Cypress.

100 messages/mo free · reset links arrive classified, not scraped
Start free Read the quickstart