Testing the signup flow end to end.
A signup is not a form. It is a journey with at least four state changes — an account exists but is gated, a verification message goes out, the gate flips, a welcome email lands — and the credential the test needs at the pivot point lives inside an email your app just sent. Most suites test the first step, sleep through the second, and assert the last one against a mock. This guide covers the whole path with one private inbox per test and no sleeps, and links down to the guide for each stage that deserves its own.
Quick answer. Create a private, routable inbox inside the test. Submit the signup form with its address. Assert the account is gated. Long-poll the inbox for the verification message and consume it as a typed field — a classified verify link or an extracted OTP — instead of scraping. Complete verification, assert the account is activated, then wait for the welcome email by subject so the test can't re-read the first message. Log in. Delete the inbox.
After this guide you can write that test in Playwright or pytest, decide which signup stages belong in an end-to-end test at all, and add the five edge-case tests — duplicate signup, resend, expiry, scanner prefetch, parallel workers — that catch the regressions a happy-path test never sees.
Signup is five stages, and each one can break alone
Trace what actually happens between "Create account" and a usable session. Every row is a separate piece of code, usually owned by a separate person, and every row has failed independently in some product you've used:
verify or an extractable codeSandbox provider key on staging; suppression list; template renders the placeholder URLverified becomes trueFollowing the link or entering the code activates the account; a second attempt is refusedToken accepted twice; GET-only verification consumed by mail scanners; code accepted for any accountA test that submits the form and asserts "account created" covers stage 1. A test that also sleeps five seconds and asserts "an email was sent" — against a mocked mailer — covers a stub of stage 2. Stages 3 to 5 are where the product either works or doesn't, and they are exactly the stages a mock can't reach, because the thing that connects them is a real message carrying a real credential.
The test smell: two emails, one inbox, one wait
The signup journey produces two messages on the same address — verification, then welcome — and that is the specific way signup tests go wrong once they do use a real inbox. The pattern looks like this:
const link = await inbox.waitForLink({ kind: 'verify' }); await page.goto(link.url); const welcome = await inbox.waitForMessage(); // ← resolves immediately… expect(welcome.subject).toContain('Welcome'); // …with the VERIFICATION email
A wait with no filter starts from inbox history, and the verification email is already there. The assertion fails on subject, someone "fixes" it by loosening the assertion to toBeTruthy(), and from that day the suite is green whether or not a welcome email is ever sent. Every welcome-email regression after that ships. The fix is a filter that can only match the second message — match: 'subject:Welcome' — and the complete spec below does exactly that.
Which stages belong in an end-to-end test
Not every suite needs the whole journey, and the whole journey doesn't belong in every suite. Signup is one feature; it should not tax the hundreds of tests that merely need a logged-in user. The architecture that scales:
The real-inbox tests are few and slow-ish by design, and they are the only tests that catch the bug class "signup is fine, but nobody can complete it" — the sandboxed provider key, the suppression list, the welcome job that silently stopped enqueuing. The strategy guide explains why the layers compose rather than replace each other.
The whole journey in one Playwright spec
Link-based verification, then the welcome email, then first login. JS SDK (mailfixture on npm, v0.6.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: gate → verify → welcome → first login', async ({ page, request }) => { const inbox = await mail.createInbox({ ttlSeconds: 900 }); // private, routable, self-expiring const password = 'a-real-test-password'; try { // stage 1 — submit; the gate holds await page.goto('/signup'); await page.fill('#email', inbox.address); await page.fill('#password', password); await page.click('text=Create account'); await expect(page.locator('.banner')).toContainText('check your email'); const gated = await request.post('/api/login', { data: { email: inbox.address, password } }); expect(gated.status()).toBe(403); // unverified accounts can't log in // stage 2 — the verification email arrives, link classified server-side const link = await inbox.waitForLink({ kind: 'verify', timeout: 30_000 }); // stage 3 — verify; the gate flips and closes behind us await page.goto(link.url); await page.click('text=Confirm'); // GET renders, POST flips — see "scanner prefetch" await expect(page.locator('h1')).toHaveText('Email verified'); await page.goto(link.url); await expect(page.locator('body')).toContainText('already been used'); // stage 4 — the welcome email is a SECOND message: filter, or you re-read the first const welcome = await inbox.waitForMessage({ match: 'subject:Welcome', timeout: 60_000 }); const links = await mail.getLinks(welcome.id); // classified: verify | reset | unsubscribe | other expect(links.some(l => l.url.startsWith('https://app.example.com/'))).toBe(true); expect(welcome.textBody).not.toMatch(/\{\{|undefined|null/); // no template debris // stage 5 — first login, with the credentials from stage 1 const session = await request.post('/api/login', { data: { email: inbox.address, password } }); expect(session.status()).toBe(200); } finally { await inbox.delete(); // TTL is the backstop for killed runs } });
Three things in that spec are load-bearing and easy to drop:
- The gated-login assertion at stage 1. Without it, a change that marks users verified at creation keeps every later assertion green while the gate silently disappears. The verification guide calls this "the gate holds" and explains why it's the first of three assertions, not an optional one.
match: 'subject:Welcome'on the second wait. The SDK's wait helpers start from inbox history; on an inbox that already holds the verification email, an unfiltered wait returns it instantly. A bare term matches the subject;from:andto:prefixes exist too.- A bigger budget on the welcome wait. Verification mail is sent in the request; welcome mail usually comes from a background job. Give the second wait the job's worst case plus provider latency, and make sure the test timeout exceeds both — Playwright's default test timeout is 30 s, smaller than the two waits in the spec combined.
At suite scale, the create/try/delete moves into a test.extend inbox fixture. The journey test itself stays one test: it's a sequence of state changes, and splitting it across tests just recreates the shared-state problem it exists to avoid.
The OTP-shaped variant, in pytest
If your signup verifies with a code instead of a link, the journey is the same; only the stage-3 mechanics change. Python SDK (mailfixture on PyPI, v0.6.x, Python 3.9+; timeouts are seconds):
from mailfixture import MailFixture mf = MailFixture() # reads MAILFIXTURE_API_KEY from the environment def test_signup_journey_with_otp(app): inbox = mf.create_inbox(ttl_seconds=900) try: # stage 1 — submit; the gate holds app.signup(email=inbox.address, password="a-real-test-password") assert app.login(inbox.address, "a-real-test-password").status_code == 403 # stage 2 + 3 — the code arrives ranked and extracted; verify with it code = inbox.wait_for_otp(timeout=30) app.verify(email=inbox.address, code=code) assert app.get_user(inbox.address)["verified"] is True assert app.verify(email=inbox.address, code=code).status_code == 400 # single use # stage 4 — the welcome email is a second message welcome = inbox.wait_for_message(match="subject:welcome", timeout=60) assert "{{" not in (welcome.text_body or "") assert any(l.kind == "other" and "/getting-started" in l.url for l in welcome.links) # stage 5 — first login assert app.login(inbox.address, "a-real-test-password").status_code == 200 finally: inbox.delete()
wait_for_otp returns the top-ranked code, scored by keyword proximity ("code", "verification", "OTP") and format. If the message carries nothing that clears the confidence threshold it keeps polling and raises MailFixtureTimeout at the deadline rather than guessing — an honest failure, and the cue to send the email in so the extractor learns it. The OTP guide owns the scoring details and the code-specific edge cases; the pytest guide owns the conftest fixture this collapses into.
Two messages over raw REST
From any other language the same journey is the REST API directly. The one signup-specific wrinkle is the second message: without SDK filters, the clean way to wait for the welcome email is a since cursor taken from the verification message's own timestamp — a wait that, by construction, cannot return message one:
# VERIFICATION_RECEIVED_AT = "received_at" from the first message's summary curl -s "https://api.mailfixture.com/v1/inboxes/$INBOX_ID/messages?wait=45&since=$VERIFICATION_RECEIVED_AT&match=subject:Welcome" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" # holds open until a matching message newer than the cursor arrives, or 45 s pass (ceiling 60; chain calls for longer budgets)
The create / long-poll / links / follow sequence for the verification half is written out in the verification guide's REST section, and the Selenium guide covers the wrapper rules for languages without an SDK — clamp wait at 60, chain via since, honor Retry-After on 429.
Failure modes
received_at of the "welcome" message predates the clickmatch: 'subject:Welcome', or a since cursor from message one--workers=1One inbox per test, created inside the test — the parallel guideotp.best is nullSend us the email; meanwhile select from otp.candidates explicitlyThe tests around the happy path
Duplicate signup. Sign up twice with the same address. What the browser shows is a security decision — "an account with this email exists" tells an attacker exactly that — and what the email does is a product decision: many apps send "you already have an account, here's a login link" to the existing address instead. Assert both halves: the second response is indistinguishable from a fresh signup in the browser, and the right message (or none) arrives in the inbox. This is the same enumeration discipline the password-reset guide applies to "forgot password" — OWASP's authentication cheat sheet spells out the generic responses for login, recovery, and account creation, and the negative half uses the same bounded wait: a short timeout whose expiry is the pass condition.
Resend verification. The user clicks "resend". Which link wins — newest, oldest, both? Whatever you promise, assert it: wait for the second verification message with since set to the first one's timestamp, then follow each link and check the outcome matches the policy.
Expiry. Verification tokens that die after 24 hours deserve a test — through a test hook that ages the token, never a sleep. Mint, age, follow, expect rejection and a way to request a new one.
Scanner prefetch. Corporate mail security fetches the links in an email before the human sees them — Microsoft's Safe Links, for one, scans URLs before delivery and detonates unknown ones in the background, and most gateway filters do the same. If verification completes on a bare GET, the scanner activates the account — and bot signups verify themselves with no human in the loop. The fix is GET renders, POST flips (the spec above clicks "Confirm" for exactly this reason), and the pin is a server-side follow of the link followed by an assertion that the account is still unverified. The verification guide tells the story of how our own invite links taught us this.
Parallel workers. Every worker creates its own inbox, so eight workers signing up at once produce eight private journeys that cannot cross-verify. The cost is inbox creations, which have a velocity limit per key; the parallel guide has the arithmetic for when a suite is big enough to notice.
Cleanup. Delete the inbox in finally and set a TTL anyway — a killed CI job never reaches finally, and the TTL sweeps up behind it. Deleting an inbox drops its messages immediately, which is what you want for test data.
What this doesn't prove
MailFixture is receive-only — it accepts the mail your app sends and never sends anything itself. Asserting on the received message proves content, links, and headers as delivered; it does not prove how Gmail or Outlook render the welcome email, and it doesn't prove inbox placement — a message that authenticates and scores as ham can still be filtered by a receiver's own rules. Link classification and OTP scoring are heuristics over the message; when they miss, select explicitly and send the email in. And the journey test covers your signup, on your staging, through your provider — it says nothing about a third party's.
Next steps
- Stage 3 in depth — email verification for links, OTP flows for codes, SMS codes if the second factor is a text.
- The welcome email's quality — content, links, SPF/DKIM/DMARC, spam score — is one assertion bundle in the transactional email guide.
- When the verification wait times out and you don't know why: the missing-email diagnostic tree.
- The quickstart: create a free inbox and run the Playwright journey above against your own signup form.