Testing email verification, end to end.
Email verification is the gate in front of your product: sign up, receive a link, click it, and only then does the account become real. It is not login — a magic link signs an existing user in; a verification link flips a verified bit that everything downstream depends on. That flip is exactly the kind of state change test suites quietly skip, because the credential the test needs is a URL inside an email your app just sent.
Quick answer. Create a private inbox inside the test, point your signup form at its address, long-poll until the verification email arrives, read the link as a classified verify field instead of scraping HTML, then click it — in the browser, or server-side via an API call — and assert the account actually flipped from gated to verified. No sleeps, no shared mailbox, no regex.
After this guide you can write that test in Playwright, in pytest, or with nothing but curl, and you'll know the four edge cases that deserve a test each — including the one where a corporate mail scanner clicks your link before any human does.
A verification test is three assertions, not one
Most "verification tests" assert only that the email showed up. That proves your app called its email provider. It does not prove verification works. The real test has three parts:
- The gate holds. Immediately after signup, the account must be restricted — can't log in, can't act, sees "check your email." If this assertion is missing, the day someone accidentally marks users verified at creation, every test stays green while the gate silently disappears.
- The flip happens. Clicking the link transitions the account to verified, observably: the user can now log in, or your API reports
verified: true. - The gate closes behind it. The token is single-use (or expires). Following the same link twice must not succeed twice.
Everything below is machinery for making those three assertions reliable.
Why these tests flake
Fixed sleeps. waitForTimeout(5000) is a bet against the tail of your delivery-latency distribution. When delivery takes longer, the test fails red on a working feature; when it takes less, the suite burns the difference, multiplied by every test.
Shared inboxes. One qa@company.com mailbox shared by parallel workers means worker A fishes worker B's verification link out of the pile. Verification tokens are bound to an account, so A's click verifies B's account — or fails with "invalid token" — intermittently, order-dependently, and never on your machine.
Scraping HTML for hrefs. querySelector('a') finds the logo link. The first-https:// regex finds the tracking pixel's host. A verification email routinely carries five or more URLs — logo, help center, unsubscribe, social footer, and somewhere in there, the button. "The verification link" is a classification problem, not a string search, and every hand-rolled extractor breaks at once the day marketing wraps the button in a click-tracker.
Asserting arrival instead of state. The test that ends at "an email arrived with a link in it" tests your email provider, not your product. The flip is the product.
The pattern: inbox-per-test, long-poll, classified link
Create the inbox inside the test — fresh, private, expiring on its own. Let the server hold the request open until the email lands. Read the link as a typed field: extraction runs on the receiving side, on every message, and each URL arrives labeled verify, reset, unsubscribe, or other.
With Playwright and the JS SDK (mailfixture on npm, v0.5.x, Node 18+; timeouts are milliseconds):
import { test, expect } from '@playwright/test'; import { MailFixture } from 'mailfixture'; const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY from the environment test('signup gates until the email is verified', async ({ page }) => { const inbox = await mail.createInbox({ ttlSeconds: 900 }); // cleans itself up await page.goto('/signup'); await page.fill('#email', inbox.address); await page.fill('#password', 'a-real-test-password'); await page.click('text=Create account'); // 1. the gate holds await expect(page.locator('.banner')).toContainText('check your email'); // 2. the flip happens const link = await inbox.waitForLink({ kind: 'verify', timeout: 30_000 }); await page.goto(link.url); await expect(page.locator('h1')).toHaveText('Email verified'); // 3. the gate closes behind it await page.goto(link.url); await expect(page.locator('body')).toContainText('already been used'); });
The timeout is a ceiling, not a duration — waitForLink resolves the moment the email arrives. And because the link comes back classified, the test survives template rewrites, tracking wrappers, and however many footer links legal adds next quarter.
Keep the API key out of the repo: MAILFIXTURE_API_KEY as a CI secret, one key per environment so a leaked staging key can be revoked without touching the rest.
No browser? Click server-side.
API-level suites don't have a page to navigate. For those, the API performs 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, redirect count. The response body is never returned, and only URLs that extraction actually found in the message can be followed.
With pytest and the Python SDK (mailfixture on PyPI, v0.5.x, Python 3.9+; timeouts are seconds):
from mailfixture import MailFixture mf = MailFixture() # reads MAILFIXTURE_API_KEY from the environment def test_verification_flips_the_account(app): inbox = mf.create_inbox(ttl_seconds=900) app.signup(email=inbox.address, password="a-real-test-password") assert app.get_user(inbox.address)["verified"] is False # the gate holds msg = inbox.wait_for_message(match="subject:verify", timeout=30) result = mf.follow_link(msg.id, kind="verify") # server-side click assert result.ok # target answered 2xx assert "/welcome" in result.final_url assert app.get_user(inbox.address)["verified"] is True # the flip happened second = mf.follow_link(msg.id, kind="verify") # the gate closes assert not second.ok inbox.delete()
Two constraints worth knowing before you reach for the server-side click:
- The target must be reachable from the public internet over HTTPS. A staging app on
localhostor behind a VPN can't be clicked this way — use the browser pattern instead. - It's a GET. If your verification endpoint (correctly — see the scanner section) confirms on POST rather than bare GET, the server-side click proves the token round-trip landed on the confirmation page; the browser test owns the full flip.
Raw REST for everything else
The SDKs are JS/TS and Python. From Java, Go, Ruby, C#, or a shell script, the same flow is four HTTP calls against https://api.mailfixture.com:
# 1. create an inbox curl -s -X POST https://api.mailfixture.com/v1/inboxes \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"ttl_seconds": 900}' # → {"id": "…", "email_address": "x7f3a9@…", …} # 2. trigger your signup flow toward that address, then long-poll: # the request holds open until a message arrives (or 45s pass; ceiling 60) curl -s "https://api.mailfixture.com/v1/inboxes/$INBOX_ID/messages?wait=45" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" # 3. read the classified links (wire field name: "class") curl -s "https://api.mailfixture.com/v1/messages/$MESSAGE_ID/links" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" # 4. server-side click on the first verify-classed link curl -s -X POST "https://api.mailfixture.com/v1/messages/$MESSAGE_ID/links/follow" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"class": "verify"}' # → {"requested_url": "…", "final_url": "…", "status": 200, "redirects": 1, "ok": true}
Then assert the flip against your own app's API, same as the Python version. Note the naming: the wire calls the link category class; both SDKs expose it as kind. Same thing.
Failure modes
querySelector('a') grabbed the wrong URLThe URL the test followed doesn't contain your verify pathAsk for the link by classification (kind: 'verify'), not by positionmatch filter doesn't match the actual subjectListing without match returns the messageLoosen the filter, or drop it — a private inbox rarely needs onefollow fails while a browser on the VPN opens the link fineUse the browser click, or give staging a publicly reachable hostnameEdge cases worth a test each
Scanner prefetch — the big one. Corporate mail security (Outlook Safe Links, and most gateway filters) GETs every URL in an email before the human sees it. If your verification completes on a bare GET, the scanner verifies the account while the message is still in transit. Sounds harmless for verification — the user was going to click anyway — until you notice what it breaks: your "gate holds" assertion races the scanner, bot signups verify themselves without a human in the loop, and your activation metrics count machines. The fix is the same one we landed on for our own invite emails after watching scanners hit them: GET renders a confirmation page, POST performs the flip. Then pin it with a test — the server-side click above is a bare GET, so follow_link followed by an assertion that the account is still unverified proves the scanner can't consume your tokens. The browser test performs the real POST click.
Single use. Follow the link twice; the second attempt must fail. This is the assertion that catches real token-handling regressions, and it only means something from a private inbox — in a shared one you can't know whose click was first.
Expiry. If verification links die after 24 hours, test it — with a test hook that ages the token, not a real sleep. Mint, age, follow, expect rejection.
Resend. Users click "resend email" — then which link wins? Newest, oldest, or both? Whatever your product promises, assert it. Record the first message's received_at, trigger the resend, then wait for the second through the message-list API with that value as since, so the test can't accidentally re-read message one. A new waitForLink call by itself starts from inbox history.
When this is the wrong tool
Be honest about which suites need the real flow. Verification is one feature; it shouldn't tax every test that merely needs a logged-in user. The usual architecture:
- A handful of end-to-end verification tests exercise the real gate → email → click → flip path, as above.
- Everything else bypasses it through a test-only hook (a seeded verified user, an API that creates pre-verified accounts in test environments). Fast, and it doesn't dilute the signal of the real tests.
- Local development is fine on a local SMTP-capture tool — instant, offline, visual. The end-to-end suite belongs in staging/CI receiving real mail on real domains, because provider misconfiguration is a bug class that only real delivery catches. The strategy guide maps which layer runs where.
What this doesn't prove
MailFixture is receive-only — it accepts the mail your app sends and never sends anything itself, so it cannot exercise your provider's sending path beyond observing what arrives. Inspecting the received message proves content, links, and headers; it does not prove how Gmail or Outlook will render the email — that's a different job we don't do. The server-side click reports how your endpoint answered (status, final URL, redirect count) and never returns the page body, so assertions on landing-page content need the browser. And link classification is a heuristic over URL paths and anchor text; if your template defeats it, select by exact url or index instead — and send us the email so the extractor learns it.
Next steps
- If the email carries a code instead of a link, the same three flake sources apply — the OTP guide covers that variant.
- If the link is the login rather than the gate in front of it — the magic-link guide.
- Password reset shares the click-the-link machinery but mutates a credential, which changes the test architecture — the password-reset guide.
- The quickstart: create a free inbox and run the Playwright example above against your own signup form in about five minutes.