Testing email flows in pytest, end to end.
pytest is a comfortable place to test email — tests are plain Python, so an inbox API is a plain function call, no bridge, no command queue. Which is exactly how suites drift: fixtures make sharing effortless, an inbox looks like a resource worth sharing, and one scope="session" later every test is reading the same mailbox. Here's the anatomy of each flake, and the ten-line conftest fixture that replaces them all.
Why email tests flake in pytest
1. time.sleep(8). pytest has no auto-waiting anywhere — no retrying locators, no built-in polling — so the wait for an email is whatever you write, and the path of least resistance is a fixed sleep: too short when delivery takes nine seconds, pure waste when it takes one. The same disease as every other OTP test, in its plainest form.
2. One inbox, widened scope. Fixtures are pytest's sharing mechanism, and scope creep is idiomatic: an inbox looks like an expensive resource, so someone promotes the fixture to scope="session" to save API calls. Now the suite passes in file order and fails in any other — each test matches against every message the tests before it received — and pytest -n auto turns the coupling into a race, with xdist workers reading each other's codes. The suite looks flaky; it's actually sharing state.
3. The hand-rolled polling loop. A while over the messages list with time.sleep(1) between attempts — or the same thing dressed up in a tenacity decorator. That's a dozen requests per test eating your rate limit, a race in every gap between polls, and — without a cursor — a match against a previous test's message. The loop isn't the fix; it's the sleep from mistake #1 wearing a retry.
The fixture
The fix is the seam pytest itself provides: conftest.py. A function-scoped inbox fixture mints a fresh address for every test that asks for one and deletes it after the yield — setup, isolation, and teardown in one place, written once. And because the server holds a long-poll open until mail actually arrives, "wait for the code" is one call, not a polling loop:
import pytest from mailfixture import MailFixture @pytest.fixture(scope="session") def mail(): return MailFixture() # reads MAILFIXTURE_API_KEY @pytest.fixture def inbox(mail): inbox = mail.create_inbox(ttl_seconds=900) yield inbox inbox.delete() # runs pass or fail; TTL covers killed runs
import requests APP = "https://staging.example.com" def test_signup_sends_otp(inbox): requests.post(f"{APP}/api/signup", json={"email": inbox.address}) otp = inbox.wait_for_otp(timeout=30) # held open server-side r = requests.post(f"{APP}/api/verify", json={"email": inbox.address, "code": otp}) assert r.status_code == 200
The timeout is now a ceiling, not a duration: wait_for_otp returns the moment the email lands — typically well under a second — and only spends the full 30 when something is genuinely broken. The test body is the same code as the four-minute quickstart; what the quickstart doesn't have room for is the rules that keep the fixture honest at suite scale.
The rules the fixture imposes
- Function-scoped inbox, session-scoped client. The client is stateless — sharing it for the whole run is free. The inbox is the test's mutable state, and a bare
@pytest.fixtureis already the right scope. Every widening —module,session— is the shared-mailbox disease from mistake #2 with tidier syntax. If a test needs two inboxes (sender and recipient personas), add a factory fixture that mints them and cleans up its own list; don't reuse one. - Reruns re-run fixtures — use that. With pytest-rerunfailures'
--reruns 2, each attempt tears down and rebuilds function-scoped fixtures, so attempt two gets a fresh inbox and can never re-read the stale code attempt one already consumed. This only holds if the inbox comes from the fixture: one minted at module import or inside a session-scoped fixture is the same inbox on every attempt. - Timeouts are seconds here. The Python SDK counts in seconds —
timeout=30is thirty seconds, and the JS idiomtimeout=30_000transplanted into Python is eight hours. Nothing catches that by default, because pytest imposes no per-test time limit: the CI job dies at its own ceiling with the suite mid-wait. If you add pytest-timeout, set it above the SDK deadline so a slow email fails as the SDK'sMailFixtureTimeout— the error that names the real problem — instead of the plugin killing a healthy long-poll. - Negative expectations are bounded waits. "No email goes to an unknown address" is not a sleep and an empty-list assert — it's
with pytest.raises(MailFixtureTimeout): inbox.wait_for_message(timeout=10). Same machinery, inverted: the timeout becomes the pass condition. Be honest about what it proves — no email within ten seconds, not never — and keep the bound tight so the suite doesn't pay for it on every run.
pytest -n auto needs no coordination: no shared mailbox, no cross-worker collisions, nothing to lock — and inboxes expire on their TTL, so a killed run cleans up after itself.Magic links and resets ride the same fixture
Nothing about the pattern is OTP-specific. inbox.wait_for_link(kind="verify") returns the confirmation link already classified — never the logo link, never the tracking pixel — and it drops straight into whatever drives your app: page.goto(link.url) in playwright-python, another requests.get in an API suite, or no client at all — follow_link performs the click server-side and reports the redirect chain. Passwordless login and password reset each deserve their own edge-case matrix — single use, expiry, scanner prefetch, sessions after reset — and each has one: magic links, password reset. And everything else extraction produces is one more call on the same message: SPF/DKIM/DMARC verdicts, a SpamAssassin score, attachment metadata.
Local dev vs CI
None of this replaces watching emails render while you build — a local SMTP-capture tool like Mailpit does that job well, with zero setup. The split that works: capture locally, deliver in CI. The CI suite receives on real domains through your real provider, which is the only place a test can catch the staging config still pointing at the sandbox. The trade-offs are laid out in MailFixture vs. Mailpit.
If the same suite drives a browser through playwright-python, everything above still holds — page replaces requests and the fixture doesn't change. If your suite is JavaScript, the architecture moves with the runner: Playwright puts the isolation in a test.extend fixture; Cypress needs the cy.task bridge.