DEV Community

Cover image for How to Test Email Verification Flows in Python with pytest
zerodrop
zerodrop

Posted on

How to Test Email Verification Flows in Python with pytest

Testing email verification in Python is one of those problems that looks simple until you actually try it.

Your app sends a verification email. Your pytest test needs to read that email, extract the OTP or magic link, and continue the test. But the email lands in an inbox your test can't reach.

The common workarounds:

  • Mock the email layer — your tests pass while production email could be broken
  • Use a shared Gmail inbox — race conditions everywhere in parallel runs
  • Use MailHog — requires Docker, doesn't test your real email provider
  • Write regex against the email body — breaks every time the template changes

There's a cleaner way.

The ZeroDrop Approach

ZeroDrop gives you real disposable email inboxes caught at Cloudflare's edge. No Docker. No shared inbox. No regex.

When an email arrives, OTP codes and magic links are auto-extracted before the email is stored. By the time your test calls wait_for_latest(), email.otp and email.magic_link are already there.

email = mail.wait_for_latest(inbox)
email.otp        # "847291" — auto-extracted, no regex
email.magic_link # "https://..." — auto-extracted, no HTML parsing
Enter fullscreen mode Exit fullscreen mode

Setup

pip install zerodrop
Enter fullscreen mode Exit fullscreen mode

No dependencies. No API key. No signup. Python 3.8+.

Basic pytest Example

import pytest
from zerodrop import ZeroDrop

mail = ZeroDrop()

def test_signup_email_verification(page):
    # Generate a unique inbox for this test — no network request
    inbox = mail.generate_inbox()

    # Sign up with the ZeroDrop inbox
    page.goto("/signup")
    page.fill('[name="email"]', inbox)
    page.fill('[name="password"]', "TestPassword123!")
    page.click('[type="submit"]')

    # Wait for the verification email
    email = mail.wait_for_latest(inbox, timeout=15000)

    # OTP is auto-extracted — no regex needed
    assert email.otp is not None
    page.fill('[name="otp"]', email.otp)
    page.click('[type="submit"]')

    assert page.url.endswith("/dashboard")
Enter fullscreen mode Exit fullscreen mode

No helper functions. No regex. No HTML parsing.

pytest Fixtures

For cleaner tests, wrap ZeroDrop in a pytest fixture:

# conftest.py
import pytest
from zerodrop import ZeroDrop

@pytest.fixture(scope="session")
def mail():
    return ZeroDrop()

@pytest.fixture
def inbox(mail):
    """Fresh inbox per test — no shared state."""
    return mail.generate_inbox()
Enter fullscreen mode Exit fullscreen mode

Then use them in tests:

# test_auth.py
def test_signup_verification(page, mail, inbox):
    page.goto("/signup")
    page.fill('[name="email"]', inbox)
    page.fill('[name="password"]', "TestPassword123!")
    page.click('[type="submit"]')

    email = mail.wait_for_latest(inbox, timeout=15000)

    assert email.otp is not None
    page.fill('[name="otp"]', email.otp)
    page.click('[type="submit"]')

    assert page.url.endswith("/dashboard")


def test_magic_link_login(page, mail, inbox):
    page.goto("/login")
    page.fill('[name="email"]', inbox)
    page.click('button:text("Send magic link")')

    email = mail.wait_for_latest(inbox, timeout=15000)

    assert email.magic_link is not None
    page.goto(email.magic_link)

    assert page.url.endswith("/dashboard")


def test_password_reset(page, mail, inbox):
    page.goto("/forgot-password")
    page.fill('[name="email"]', inbox)
    page.click('[type="submit"]')

    email = mail.wait_for_latest(inbox, timeout=15000)

    assert email.magic_link is not None
    page.goto(email.magic_link)

    page.fill('[name="password"]', "NewPassword123!")
    page.click('[type="submit"]')

    assert page.locator(".success-message").is_visible()
Enter fullscreen mode Exit fullscreen mode

Email Filtering

When your signup flow sends multiple emails — welcome email, verification email — filter to target the right one:

from zerodrop import ZeroDrop, ZeroDropFilter

mail = ZeroDrop()

def test_signup_otp_only(page, inbox):
    page.goto("/signup")
    page.fill('[name="email"]', inbox)
    page.click('[type="submit"]')

    # Only catch the verification email
    email = mail.wait_for_latest(
        inbox,
        timeout=15000,
        filter_=ZeroDropFilter(
            from_="noreply@yourapp.com",
            has_otp=True,
        )
    )

    assert email.otp is not None
    page.fill('[name="otp"]', email.otp)
    page.click('[type="submit"]')
Enter fullscreen mode Exit fullscreen mode

All string filters are case-insensitive partial matches.

Parallel Test Runs — No Collisions

generate_inbox() runs locally with no network request. Each worker gets a unique inbox automatically:

# pytest.ini or pyproject.toml
# [tool.pytest.ini_options]
# addopts = "-n auto"  # pytest-xdist parallel execution

# Each test gets its own inbox — generate_inbox() is local, instant
def test_user_a(page, mail):
    inbox = mail.generate_inbox()  # unique per test
    # ...

def test_user_b(page, mail):
    inbox = mail.generate_inbox()  # different inbox, zero collision
    # ...
Enter fullscreen mode Exit fullscreen mode

50 parallel workers. 50 isolated inboxes. Zero race conditions.

GitHub Actions CI

name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - run: playwright install --with-deps chromium
      - run: pytest tests/e2e/ -v
Enter fullscreen mode Exit fullscreen mode

No Docker. No SMTP service. No API keys in CI secrets. ZeroDrop works out of the box.

Why Not Use MailHog?

MailHog is a local SMTP server that catches emails in development. It works fine locally — but in CI it requires:

  • A Docker service block in your GitHub Actions YAML
  • Running against a local app instance, not your real staging environment
  • Manual OTP extraction via regex on the raw email body

ZeroDrop works against your real staging environment, in real CI, with no infrastructure overhead. email.otp is just there.

MailHog ZeroDrop
Docker required
Tests real email provider
OTP auto-extraction
Magic link extraction
Parallel-safe
CI setup Complex None

ZeroDropEmail Fields

email.otp        # "123456" — 4-8 digit code, or None
email.magic_link # "https://app.com/verify?token=abc" — or None
email.subject    # "Verify your email"
email.body       # Full plain-text body
email.from_      # Sender address
email.received_at # datetime
Enter fullscreen mode Exit fullscreen mode

Both otp and magic_link are None if not detected. Always assert before using:

assert email.otp is not None
page.fill('[name="otp"]', email.otp)
Enter fullscreen mode Exit fullscreen mode

Conclusion

Testing email verification in Python doesn't require Docker, regex, or a shared inbox. ZeroDrop gives each pytest test a real isolated inbox, with OTP and magic link auto-extracted at the edge.

email.otp is just there. No regex. No HTML parsing. No maintenance.

Free to use. No signup required. Works in CI out of the box.

zerodrop.dev · PyPI · GitHub

Top comments (0)