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.
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
- Single use. Follow the link, reset, then follow it again. The second attempt must fail. A replayed reset token is a real vulnerability class, and this is a three-line regression test for it.
- Expiry. If tokens die after an hour, prove it: mint one, advance your app's clock through a test hook (never a real sleep), follow, expect rejection.
- Sessions after reset. If your product promises "resetting your password signs out other devices," hold a second logged-in session while you reset and assert it's dead afterwards. This is the assertion that catches the account-takeover-recovery regression — and almost nobody writes it.
- Scanner prefetch. Corporate mail filters GET every link before the human sees it. If a bare GET consumes the token, your users' resets die in transit. Assert that GET renders a form and only the POST changes anything — the same peek-then-act rule magic links need.
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.
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.
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.