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
Setup
pip install zerodrop
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")
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()
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()
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"]')
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
# ...
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
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
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)
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)