You built passwordless login. The user enters their email, your app sends a magic link, they click it, they're in. Elegant — until you try to write an automated test for it.
The problem: the flow spans two systems (your app and an email server) with an asynchronous handoff in between. Mocking the mailer lets you test that a link was generated, but it doesn't test that clicking the link actually creates a session. Here's the full integration approach.
Why Mocking Isn't Enough
The typical unit test mocks the email transport and asserts that the magic link was generated:
test("generates magic link", async () => {
const spy = jest.spyOn(mailer, "send");
await requestMagicLink("user@test.com");
expect(spy).toHaveBeenCalled();
const link = spy.mock.calls[0][1].match(/https://.*/auth?token=w+/);
expect(link).toBeTruthy();
});
This tests half the flow. It doesn't test:
- Whether the email actually arrives via SMTP
- Whether the token in the email matches what's in the database
- Whether clicking the link creates a valid session
- Whether expired or replayed tokens are properly rejected
The Full Integration Test
The flow has four steps: request → capture → parse → consume.
Step 1: Request the Magic Link
// Your app sends via SMTP to MailHog.site
const res = await fetch("http://localhost:3000/api/auth/magic-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "testuser@example.com" }),
});
expect(res.status).toBe(200);
Step 2: Capture the Email
// Poll MailHog.site API for the captured email
async function waitForEmail(recipient, timeout = 5000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const r = await fetch(
`${MAILHOG_API}/emails?to=${recipient}&limit=1`,
{ headers: { Authorization: `Bearer ${API_KEY}` } }
);
const emails = await r.json();
if (emails.length > 0) return emails[0];
await new Promise(r => setTimeout(r, 300));
}
throw new Error("Email not received within timeout");
}
const email = await waitForEmail("testuser@example.com");
Step 3: Parse the Magic Link
// Extract the magic link from the email body
const linkMatch = email.html_body.match(
/href="(https?://[^"]*/auth?token=[a-zA-Z0-9_-]+)"/
);
expect(linkMatch).toBeTruthy();
const magicLink = linkMatch[1];
Step 4: Consume the Token
// Click the link and verify session creation
const authRes = await fetch(magicLink, { redirect: "manual" });
expect(authRes.status).toBe(302); // redirect to dashboard
const cookies = authRes.headers.get("set-cookie");
expect(cookies).toContain("session="); // session cookie set
Python/pytest Version
import httpx, re, time
def test_magic_link_flow():
# Step 1: Request
httpx.post("http://localhost:3000/api/auth/magic-link",
json={"email": "testuser@example.com"})
# Step 2: Capture
email = None
for _ in range(10):
r = httpx.get(f"{MAILHOG_API}/emails",
params={"to": "testuser@example.com", "limit": 1})
if r.json():
email = r.json()[0]
break
time.sleep(0.5)
assert email is not None
# Step 3: Parse
match = re.search(r'href="(https?://[^"]*?/auth?token=[w-]+)"',
email["html_body"])
assert match
magic_link = match.group(1)
# Step 4: Consume
r = httpx.get(magic_link, follow_redirects=False)
assert r.status_code == 302
assert "session" in r.cookies
Testing Edge Cases
- Token expiration: Request a magic link, wait for the expiry window (or mock the clock), then try to use it. Assert a 401 or 410 response.
- Replay attacks: Use a magic link successfully, then try to use the same link again. It should be rejected.
- Wrong email: Request a magic link for user A, check that no email arrives for user B.
- Rate limiting: Request 10 magic links in rapid succession. Assert that the rate limiter kicks in after the configured threshold.
For the CI pipeline setup, see our guide on testing email in GitHub Actions. For more on testing password reset flows (a similar pattern), see test password reset emails.
FAQ
How do I test magic links in Playwright or Cypress?
Use the same pattern: trigger the flow in the browser, then use the test runner's HTTP client to poll the MailHog API for the captured email. Parse the link and navigate to it in the browser context. Playwright's request fixture and Cypress's cy.request() both work for API calls alongside browser interactions.
What if my magic link has a short expiry window?
Email capture is near-instant (under 200ms). The bottleneck is your test's polling interval. With 300ms polling and a 5-second timeout, you'll capture the email well within any reasonable expiry window (typically 10-30 minutes).
Can I test that the magic link email looks correct visually?
Yes. After capturing the email, use MailHog's HTML compatibility checker to verify it renders correctly across email clients. This catches template issues before your users see them.