TL;DR: Replace your shared test email account with a per-test disposable inbox. One Pytest fixture, zero infrastructure, works in parallel CI.
The Problem
You have a Pytest test that exercises a real sign-up or notification flow:
def test_registration_sends_welcome_email():
# 1. Register a user
# 2. Check the inbox for a welcome email
# 3. Assert subject line and content
You're using a shared test@yourcompany.com inbox. It works in isolation but breaks the moment you run tests in parallel — multiple workers fight over the same inbox. One test reads the email meant for another. A stale email from the previous run triggers a false pass.
The fix is not "add a longer sleep." The fix is inbox isolation.
Why Shared Inboxes Break Parallel Tests
When two tests share the same email address:
- Test A registers
user_a@shared-inbox.com - Test B registers
user_b@shared-inbox.com(same address, different user object) - Test A polls the inbox and reads Test B's email
- Test A asserts on the wrong user's welcome message → false pass or false fail
This gets worse at scale. 8 workers, 8 tests, 1 inbox = chaos.
The Solution: Per-Test Disposable Inboxes
Create a fresh inbox before each test. Give that test's unique address to the sign-up form. Poll only that inbox. Let it expire when done.
Implementation with MinuteMail
MinuteMail has a REST API for creating short-lived mailboxes and reading their contents. There's also an open-source Python SDK.
Install the SDK:
pip install minutemail
Set your API key:
export MINUTEMAIL_API_KEY=your_key_here
The Fixture
# conftest.py
import pytest
import os
import time
import requests
API_KEY = os.environ["MINUTEMAIL_API_KEY"]
BASE_URL = "https://api.minutemail.co/v1"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
@pytest.fixture
def isolated_inbox():
"""Creates a fresh MinuteMail inbox for each test. Expires automatically."""
response = requests.post(
f"{BASE_URL}/mailboxes",
headers=HEADERS,
json={"domain": "minutemail.cc", "expiresIn": 10}, # 10-minute TTL — more than enough for a test
)
response.raise_for_status()
mailbox = response.json()
yield mailbox # provide { "id": ..., "address": ..., "expiresAt": ... }
# Inbox expires on its own — no teardown needed
def wait_for_email(mailbox_id: str, timeout: int = 30) -> dict:
"""Poll the inbox until an email arrives or timeout is reached."""
deadline = time.time() + timeout
while time.time() < deadline:
response = requests.get(
f"{BASE_URL}/mailboxes/{mailbox_id}/mails",
headers=HEADERS,
)
response.raise_for_status()
messages = response.json().get("items", [])
if messages:
return items[0]
time.sleep(2)
raise TimeoutError(f"No email received in mailbox {mailbox_id} within {timeout}s")
The Test
# test_registration.py
import re
def test_welcome_email_sent_after_registration(client, isolated_inbox):
# isolated_inbox["address"] is a unique address for this test only
email_address = isolated_inbox["address"]
response = client.post("/register", json={
"email": email_address,
"password": "SecurePass123!"
})
assert response.status_code == 201
# Poll for the welcome email
email = wait_for_email(isolated_inbox["id"], timeout=30)
assert "Welcome" in email["subject"]
assert email_address in email["body"]
def test_password_reset_email_contains_link(client, isolated_inbox):
# Each test gets its own inbox — no interference from other tests
email_address = isolated_inbox["address"]
# Create and then request reset for this user
client.post("/register", json={"email": email_address, "password": "OldPass123!"})
client.post("/reset-password", json={"email": email_address})
email = wait_for_email(isolated_inbox["id"], timeout=30)
# Extract reset link
link = re.search(r'https?://\S+reset\S+', email["body"])
assert link is not None, "Reset link not found in email body"
Running in Parallel
pip install pytest-xdist
pytest -n 8 # 8 parallel workers, each with its own inbox
Because each worker gets its own isolated_inbox fixture instance with a unique address, there is zero shared state between workers.
Using the Python SDK (Alternative)
If you'd rather use the official SDK than raw requests:
# conftest.py (SDK version)
import pytest
from minutemail import MinuteMail
client = MinuteMail(api_key=os.environ["MINUTEMAIL_API_KEY"])
@pytest.fixture
def isolated_inbox():
mailbox = client.create_mailbox(ttl=10)
yield mailbox
def wait_for_email(mailbox, timeout=30):
import time
deadline = time.time() + timeout
while time.time() < deadline:
messages = mailbox.get_messages()
if messages:
return items[0]
time.sleep(2)
raise TimeoutError("No email received")
SDK source: https://github.com/minutemailco/minutemail-python
CI/CD Integration
# .github/workflows/test.yml
- name: Run tests
env:
MINUTEMAIL_API_KEY: ${{ secrets.MINUTEMAIL_API_KEY }}
run: pytest -n 4 --tb=short
Free tier: 100 API calls/day. Each test uses 2–5 calls (create + poll attempts). For larger CI pipelines with many test workers, the Pro plan gives 10,000 calls/day.
Handling Slow Email Providers
Some email providers (particularly during cold starts or heavy load) can take 10–30 seconds. Tune the timeout and poll interval:
def wait_for_email(mailbox_id, timeout=60, poll_interval=3):
deadline = time.time() + timeout
while time.time() < deadline:
messages = requests.get(
f"{BASE_URL}/mailboxes/{mailbox_id}/mails",
headers=HEADERS
).json().get("items", [])
if messages:
return items[0]
time.sleep(poll_interval)
raise TimeoutError(...)
Summary
- Shared inboxes cause non-deterministic failures in parallel test suites
- Per-test inbox isolation eliminates the shared-state problem entirely
- A
pytest.fixturewrapping a disposable inbox API makes this a one-liner in each test - The MinuteMail API + Python SDK handles the mailbox lifecycle; tests just
yieldand move on
Tags: #testing #python #pytest #automation
Created: 2026-02-26
Top comments (0)