GUIDES / SIGNUP FLOWS

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:

STAGEWHAT CHANGESWHAT A TEST ASSERTSHOW IT BREAKS
1. SubmitAn account row exists in a pending stateThe UI says "check your email"; the API reports the account as unverified; login is refusedUsers created already-verified; login works before the click
2. Verification sentYour app hands a verification link or code to your email providerA message arrives at the address entered — with the right subject, sender, and a link classified verify or an extractable codeSandbox provider key on staging; suppression list; template renders the placeholder URL
3. VerifyThe gate flips: verified 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 account
4. WelcomeAn onboarding email goes out, often from a background jobA second message arrives, subject "Welcome", with links that resolveJob never enqueued; welcome sent before verification; links point at localhost
5. First loginA session for the new accountThe credentials from stage 1 open a session now, and only nowPassword hashed with the wrong pepper on signup; verified flag not read by the login path

A 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:

the smell
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:

SUITELAYERSIGNUP COVERAGE
Unit testsMocked mailerTemplate selection, personalization variables, "verification link contains the token" — stage 2's intent, not its delivery
Local developmentSMTP capture (Mailpit and friends)Watch the two emails render while you build the flow — fast, offline, visual
CI against stagingReal inbox, real deliveryA handful of full-journey tests: all five stages plus the edge cases below, through your real provider on real domains
Everything else in CIBypassA test-only hook seeds pre-verified users; those tests never touch email

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):

signup-journey.spec.ts · Playwrightnpm i -D mailfixture
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:

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):

test_signup_journey.py · pytestpip install mailfixture
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:

welcome.sh · after verification, wait for the second message only
# 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

SYMPTOM & CAUSEEVIDENCEFIX
Welcome assertion fails on subject — the second wait returned the verification emailreceived_at of the "welcome" message predates the clickmatch: 'subject:Welcome', or a since cursor from message one
Welcome wait times out, verification was fine — the onboarding job never ran, or runs on a scheduleInbox holds exactly one message; app logs show no enqueueThat's the bug this test exists for; if the job is legitimately delayed, raise the budget or trigger it explicitly in test environments
Green in CI, users can't complete signup — the test asserted "email sent" against a mockStaging provider key is a sandbox; production sends nothingReceive the real message in CI — stages 2 to 5 need real delivery
Login succeeds before verificationFails deterministically from the first runProduct bug: the login path ignores the verified flag. Keep the stage-1 assertion
Parallel workers verify each other's accounts — one shared inboxFailures correlate with worker count, vanish with --workers=1One inbox per test, created inside the test — the parallel guide
Runner kills the test mid-wait — test timeout below the two waits combined"Timeout of 30000ms exceeded" while the inbox later shows the message arrivedSet the test timeout above verification wait + welcome wait (Playwright's default is 30 s, and it includes fixture setup); the SDK deadline is a ceiling, not a duration
OTP extraction raises — the code isn't near a keyword, or is 3 digitsMessage visible in the dashboard; otp.best is nullSend us the email; meanwhile select from otp.candidates explicitly

The 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.

NOTE Test signup flows you own. Programmatic inboxes exist to exercise your product's signup on your environments; using them to register accounts on services that aren't yours is against the terms and gets accounts frozen.

Next steps

100 messages/mo free · one private inbox per signup, no sleeps
Start free Read the quickstart