TEST RUNNERS

Test email verification in Python with pytest

Email confirmation is one of the most common flows in a web app and one of the most reliably broken in automated tests. In Python suites the usual fix is to monkeypatch the mail backend, which tests that your view scheduled an email but not that a user ever gets a working code.

When you want the real thing inside pytest, receive an actual email with a disposable inbox. This post uses MailSink with requests and a pytest fixture, so each test gets its own inbox and asserts on the real OTP.

TL;DR

Skip SMTP and IMAP. Call a disposable-inbox API from pytest: create an inbox, run your signup against its address, wait for the code, assert it. A fixture keeps each test’s inbox isolated.

import os
import re
import requests

API = "https://api.mailsink.dev/v1"
AUTH = {"Authorization": f"Bearer {os.environ['MAILSINK_API_KEY']}"}

def test_signup_emails_a_valid_code():
    inbox = requests.post(f"{API}/inboxes", headers=AUTH).json()

    signup(inbox["address"])  # your app's real signup path

    res = requests.get(
        f"{API}/inboxes/{inbox['id']}/wait-for-code",
        params={"timeout": 30},
        headers=AUTH,
    ).json()

    assert re.fullmatch(r"\d{6}", res["code"])

Why monkeypatching the mail backend falls short

Patching Django’s django.core.mail or a FastAPI mailer to a locmem backend is fast and dependency-free, and it is the right tool when you only assert that an email was queued. It goes quiet on the failures that matter most:

A test that receives the real message catches all of these, because it reads the code a real inbox got, not the one your code intended to send.

A fixture per test

The cleanest pytest pattern is a fixture that provisions a fresh inbox and hands it to the test. One inbox per test means no cross-test bleed, and there is no teardown to write because MailSink deletes inbox contents after the TTL.

import pytest

@pytest.fixture
def inbox():
    return requests.post(f"{API}/inboxes", headers=AUTH).json()

def test_password_reset_sends_a_link(inbox):
    request_password_reset(inbox["address"])
    res = requests.get(
        f"{API}/inboxes/{inbox['id']}/wait-for-link",
        params={"timeout": 30},
        headers=AUTH,
    ).json()
    assert "/reset?token=" in res["link"]

wait-for-code returns {"code": "..."} and wait-for-link returns {"link": "..."}. The API extracts both from the message, so your assertion works on a value rather than parsing an email body. On timeout the field comes back null, so a stricter test can assert it is not None before matching.

Long poll instead of sleep

Do not time.sleep(10) and hope the mail arrived. The wait endpoints are long polls: they hold the connection until a message with an extractable value lands or the timeout hits. That removes the guesswork that makes email tests flaky and keeps the test as fast as the email allows. timeout is capped at 60 seconds; 30 is a sensible default for transactional mail.

Parametrizing across senders

If you verify several providers, parametrize keeps it to one test body. Each case still gets its own inbox from the fixture:

@pytest.mark.parametrize("provider", ["stripe", "clerk", "supabase"])
def test_provider_otp_arrives(inbox, provider):
    trigger_provider_signup(provider, inbox["address"])
    res = requests.get(
        f"{API}/inboxes/{inbox['id']}/wait-for-code",
        params={"timeout": 30},
        headers=AUTH,
    ).json()
    assert res["code"] is not None

What this costs

Each test creates one inbox. The free tier is 50 inboxes per month, enough for a signup suite that runs a few times a day. Higher-volume CI moves to the $15/mo Pro plan (2,000 inboxes per month). Inboxes are short-lived, so you pay for test runs, not idle mailboxes.

FAQ

Is there a Python SDK for MailSink?

Not yet. You call the REST API with requests (or httpx), which keeps dependencies minimal and the code obvious. The three endpoints a test needs are create inbox, wait for code, and wait for link.

Should I mock email or receive a real one in pytest?

Monkeypatch the mail backend when you only need to assert an email was queued with the right recipient. Receive a real email when the test must prove delivery, template rendering, and a usable code end to end. Keep both, at different layers of the suite.

How do I stop email tests from being flaky?

Replace time.sleep with the wait-for-code long poll, which returns the moment the mail arrives instead of after a fixed guess. Give each test its own inbox via a fixture so parallel runs with pytest-xdist never read each other’s mail.

Does this work with Django and FastAPI?

Yes. The inbox API is framework-agnostic HTTP. Point your Django or FastAPI signup flow at the inbox address, then read the code back with requests. Nothing about your app framework changes.

Can I run these tests in CI?

Yes. Store the API key as a CI secret and read it from the environment. See the GitHub Actions walkthrough for a full pipeline.

Next