Django email tests: what mail.outbox checks and what it can't

Updated October 17, 2026 · Zyntra

Django's test runner swaps your email backend for an in-memory one, so no test sends real email, including browser tests on LiveServerTestCase. mail.outbox is the right place to check who gets which email and what it says. To test delivery, links and your real backend settings, run a few browser tests against a deployed staging site that sends through your real provider.

What the test runner does to email

Before the tests run, Django's setup_test_environment() sets EMAIL_BACKEND to django.core.mail.backends.locmem.EmailBackend and creates an empty mail.outbox. Every email Django sends during the run is appended there as an EmailMessage. Django's TestCase classes empty the outbox at the start of each test.

tests/test_signup.py
from django.core import mail
from django.test import TestCase


class SignupEmailTest(TestCase):
    def test_signup_sends_confirmation(self):
        self.client.post("/signup/", {"email": "[email protected]", "password": "a-long-test-password"})

        self.assertEqual(len(mail.outbox), 1)
        message = mail.outbox[0]
        self.assertEqual(message.to, ["[email protected]"])
        self.assertIn("Confirm", message.subject)
        self.assertIn("/confirm/", message.body)

Browser tests on LiveServerTestCase send nothing either

LiveServerTestCase serves your app from a thread in the test process, with the same settings. So a Selenium or Playwright test that signs up through the browser still writes to mail.outbox, and the test can read the link from there:

tests/test_signup_browser.py
import re

from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.core import mail
from selenium import webdriver
from selenium.webdriver.common.by import By


class SignupBrowserTest(StaticLiveServerTestCase):
    def test_confirmation_link(self):
        driver = webdriver.Chrome()
        try:
            driver.get(f"{self.live_server_url}/signup/")
            driver.find_element(By.NAME, "email").send_keys("[email protected]")
            driver.find_element(By.NAME, "password").send_keys("a-long-test-password")
            driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

            link = re.search(r"https?://\S+/confirm/\S+", mail.outbox[0].body).group(0)
            driver.get(link)
            self.assertIn("Email confirmed", driver.page_source)
        finally:
            driver.quit()

That is fast and needs no network. It also means the link points at live_server_url, the email never leaves Python, and your production backend settings are never read.

What mail.outbox can't check

  • Your backend settings. EMAIL_HOST, credentials, TLS, or the API key of an email package such as django-anymail: a wrong value on staging is invisible to the test run.
  • The sender domain. DEFAULT_FROM_EMAIL on a domain your provider has not verified is rejected or lands in spam; locmem accepts any From.
  • The link host. If links are built from the Sites framework or a SITE_URL setting, staging can send production links. The outbox test sees whatever host the test sets up.
  • Templates rendered by the provider. If your email package sends a template ID and the provider fills it in, the outbox holds only the template ID and the data you passed.

On staging: a real inbox

Run a small set of browser tests against the deployed staging site, where the real backend sends real email, and read it from a test inbox over HTTP. This is plain pytest, not a Django test case, because the app under test is not in this process.

e2e/test_signup_staging.py
# pip install selenium requests pytest
import os
import uuid

import requests
from selenium import webdriver
from selenium.webdriver.common.by import By

BASE_URL = os.environ["STAGING_URL"]
TEAM = os.environ["ZYNTRA_TEAM_ID"]


def last_email(address):
    # The server waits up to 30 s for the first email to the address.
    r = requests.get("https://api.zyntra.app/emails/last", params={"email": address},
                     headers={"X-API-Key": os.environ["ZYNTRA_API_KEY"]}, timeout=45)
    r.raise_for_status()
    return r.json()


def test_signup_email_on_staging():
    address = f"{TEAM}.django-{uuid.uuid4().hex[:8]}@zyntramail.com"
    driver = webdriver.Chrome()
    try:
        driver.get(f"{BASE_URL}/signup/")
        driver.find_element(By.NAME, "email").send_keys(address)
        driver.find_element(By.NAME, "password").send_keys("a-long-test-password")
        driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

        email = last_email(address)
        links = [l["url"] for l in email["links"] if "/confirm/" in l["url"]]
        assert len(links) == 1
        assert links[0].startswith(BASE_URL)  # not a production link

        driver.get(links[0])
        assert "Email confirmed" in driver.page_source
    finally:
        driver.quit()

Questions

Can I make one Django test send real email?
Yes: @override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend") on that test replaces the locmem backend while it runs, and your SMTP settings are used. It still runs against the test server, so links point at live_server_url.
Does mail.outbox work with Celery tasks?
Only if the task runs in the test process, for example with Celery's eager mode. A real worker is another process, with its own settings, and its emails never reach the outbox.