GUIDE

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:

login.spec.ts · Playwright
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.

test_login.py · pytest
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

NOTE Magic links are how you sign in to MailFixture itself — every edge case above is a test we had to write for our own login before recommending it to you.

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.

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