Testing magic-link login, end to end.
Passwordless login is a gift to users and a headache for test suites: the credential your test needs is a URL inside an email your app just sent. Most teams either skip the test or build something fragile around a shared mailbox and a regex. Both choices come due eventually — here's the version that doesn't.
Why magic-link tests flake
1. Fixed sleeps. The same disease as OTP tests: waitForTimeout(5000) fails red when mail takes six seconds and burns four when it takes one. Delivery latency is a distribution, not a constant, and a sleep is a bet against its tail.
2. Shared inboxes. A magic link is single-use almost everywhere, which makes the shared qa@company.com mailbox worse than usual: two parallel workers request links, each fishes the other's link out of the pile, and one of them logs in as the wrong session while the other gets "link expired". The failure is intermittent, order-dependent, and unreproducible on your machine.
3. Scraping HTML for hrefs. querySelector('a') finds the logo link. The regex that grabs the first https:// finds the tracking pixel's host. And the day marketing routes the button through a click-tracker, every hand-rolled extractor in the company breaks at once. An email routinely carries five or more links; "the login link" is a classification problem, not a string search.
The pattern that doesn't
Create the inbox inside the test, let the server hold a long-poll open until the mail lands, and read the link as a typed, classified field — extraction runs on the receiving side, on every message, and each URL arrives labeled verify, reset, unsubscribe, or other:
test('magic-link login signs the user in', async ({ page }) => { const inbox = await mail.createInbox({ ttlSeconds: 900 }); // fresh, private, expires itself await page.goto('/login'); await page.fill('#email', inbox.address); await page.click('text=Email me a link'); const link = await inbox.waitForLink({ kind: 'verify', timeout: 30_000 }); await page.goto(link.url); // the click, in the same browser context await expect(page.locator('h1')).toHaveText('Welcome back'); });
The timeout is a ceiling, not a duration — the wait resolves the moment the email arrives, typically well under a second after your app sends it. And because the link comes back classified, the test survives template rewrites, tracking wrappers with the original URL swapped in, and however many footer links legal adds next quarter.
No browser? Click server-side.
API-level suites don't have a page to navigate. For those, the API can perform the click for you: it GETs the extracted link server-side, follows up to five redirects, and reports how the target answered — final URL, status, and redirect count. The response body is never returned, and only URLs that extraction actually found in the message can be followed.
msg = inbox.wait_for_message(match="subject:sign in", timeout=30) result = mf.follow_link(msg.id, kind="verify") # server-side GET, redirects followed assert result.ok # target answered 2xx assert "/welcome" in result.final_url
This proves the token round-trip works — link minted, link honored, user landed — without standing up a browser. What it can't do is assert on the landed page's contents; when you need that, use the Playwright shape above.
The edge cases worth a test each
- Single use. Follow the link twice. The second attempt must fail — this is the assertion that catches real token-handling regressions, and it needs a private inbox to mean anything.
- Expiry. If links die after 30 minutes, test it: mint one, advance your app's clock (a test hook beats a real sleep), follow, expect rejection.
- Scanner prefetch. Corporate mail filters GET every link in an email before the human sees it. If your login completes on a bare GET, the scanner consumes the token and your user gets "link expired". Assert that a plain GET renders a confirmation step and only a deliberate action signs in — we learned this building our own invite emails, which is why their links peek on GET and join on POST.
- Resend. Request a second link, then assert whichever policy your product promises: newest wins, oldest invalidated, or both live until expiry. Use a
sincecursor to wait for the second message specifically.
Local dev vs CI
While you're building the flow, a local SMTP-capture tool like Mailpit gives you instant visual feedback — keep it. The CI suite is a different job: it should receive on real domains through your real provider, because that's the only place a test can catch the staging config that still points at the email sandbox. The split is laid out in MailFixture vs. Mailpit.
If your flow sends a code instead of a link, the same three mistakes flake the same way — the OTP guide covers that variant. And if an AI agent is the one clicking through signup, give it the inbox directly over MCP: AI agents & email.