In a Django TestCase, how do you assert that a sign-up view sends exactly one welcome email from an overridden DEFAULT_FROM_EMAIL?
answer
- no real mail leaves a test run
- the runner installs an in-memory backend
- a list emptied before each test
- len(mail.outbox), then to and from_email
basics
~10 sDjango's test runner swaps in the locmem email backend, which appends every sent message to django.core.mail.outbox. Post to the view under @override_settings(DEFAULT_FROM_EMAIL=...), then assert len(mail.outbox) == 1 and inspect that message.
solid answer
~40 sDuring a test run Django replaces the configured email backend with `django.core.mail.backends.locmem.EmailBackend` — in Django 6.1 for every alias in `MAILERS` when that setting is defined, otherwise via `EMAIL_BACKEND` — so nothing leaves the machine and each sent message is copied into `django.core.mail.outbox`. Django's test case classes empty that list at the start of every test. So decorate the test with `@override_settings(DEFAULT_FROM_EMAIL='[email protected]')`, post to the sign-up URL with `self.client`, assert `len(mail.outbox) == 1`, then check `subject`, `to` (a list) and `from_email` on `mail.outbox[0]`. Read it as `mail.outbox` after `from django.core import mail`, never `from django.core.mail import outbox`, because a fresh list is assigned for each test.
code
python · 10 linesfrom django.core.mail import send_mail
def send_welcome_email(user):
send_mail(
'Welcome aboard',
f'Hi {user.email}, thanks for signing up.',
None, # None: DEFAULT_FROM_EMAIL is read when the message is built
[user.email],
)go deeper
Remember that Django tests never send real email: messages land in django.core.mail.outbox, emptied before each test, and you assert its length and fields.
Explain who installs the locmem backend, how the 6.1 MAILERS setting changes that, why from_email=None picks up an overridden DEFAULT_FROM_EMAIL, and why the outbox must be read through the module.
Recognise why an outbox is empty when mail is sent in on_commit or by another process, and choose between executing callbacks in-process and asserting that work was scheduled.
Decide how far email tests should go: asserting recipients and intent in unit tests, and leaving template rendering and deliverability to a smaller set of dedicated checks.
## Why no real email is sent during tests When Django's test runner starts, it calls `django.test.utils.setup_test_environment()`. Among other things (forcing `DEBUG=False`, adding `testserver` to `ALLOWED_HOSTS`) it swaps the email configuration for the **locmem backend**, `django.core.mail.backends.locmem.EmailBackend`: - in **Django 6.1**, if the project defines the new `MAILERS` setting, every alias in it is pointed at the locmem backend; - otherwise the deprecated-but-still-supported `EMAIL_BACKEND` setting is set to the locmem backend. The original configuration is restored when the run ends. The practical result: `send_mail()`, `EmailMessage.send()` and everything built on them never reach a mail server in a Django test run, and you do not configure anything to get that. ## What the outbox holds The locmem backend appends to a module attribute, **`django.core.mail.outbox`**, a plain Python list. That attribute exists only while the locmem backend is in use. | What to assert | Where it lives on `mail.outbox[i]` | |---|---| | how many messages were sent | `len(mail.outbox)` | | subject line | `.subject` | | recipients | `.to` (a list), `.cc`, `.bcc` | | sender | `.from_email` | | plain-text body | `.body` | | HTML part | `.alternatives`, named tuples of `content` and `mimetype` | | which mailer alias sent it (6.1+) | `.sent_using` | Three details matter. Each entry is a **deep copy** of the message, so changing the original object after sending does not change what the test sees. The backend still builds the MIME message before storing it, so invalid headers fail in tests just as they would in production. And `from_email=None` means the message falls back to `settings.DEFAULT_FROM_EMAIL` when the message object is created, which is why overriding that setting in the test changes the sender. ## Emptying between tests Django's `SimpleTestCase`, `TransactionTestCase` and `TestCase` all empty the outbox at the start of every test, so each test starts from zero and a count is never a running total. To reset in the middle of one test — for example after a setup step that itself sends mail — assign a new list: `mail.outbox = []`. ## Writing the welcome-email test 1. Decorate the test (or the class) with `@override_settings(DEFAULT_FROM_EMAIL='[email protected]')` so the expected sender is fixed by the test, not by whatever the project uses. 2. Post valid sign-up data with `self.client.post(...)` and assert the response you expect, usually a redirect. 3. Assert `len(mail.outbox) == 1` — exactly one, which also catches a double send. 4. Take `message = mail.outbox[0]` and assert `message.to`, `message.from_email` and a stable part of `message.subject`. 5. Assert on a meaningful fragment of `message.body`, not the whole rendered text, so copy edits do not break the test. ## Traps that make the outbox look wrong - **Importing the list directly.** `from django.core.mail import outbox` binds the list object that existed at import; Django assigns a new list for each test, so the imported name goes stale. Always read `mail.outbox`. - **A module-level sender.** If the view module does `SENDER = settings.DEFAULT_FROM_EMAIL` at import, the override never reaches it and the assertion on `from_email` fails. Read the setting when sending, or pass `None` and let Django fall back to it. - **Sending in `transaction.on_commit()`.** A `TestCase` never really commits, so the callback never runs and the outbox stays empty. Wrap the request in `self.captureOnCommitCallbacks(execute=True)`. - **Sending from another process.** If a background worker process sends the email, the test process's outbox never sees it; the test must run that work in-process or assert that it was scheduled. - **Mocking `send_mail` by habit.** The outbox already isolates the test from the network and checks the real message, so a mock usually tests less. ## What interviewers listen for That the candidate knows the runner, not the project settings, installs the in-memory backend; that the outbox is emptied per test; and that they assert count, recipients and sender rather than only that something was sent.
- The test starts failing with an empty outbox after the view moves send_welcome_email() into transaction.on_commit(). Why?`TestCase` runs every test inside an atomic block that is rolled back, so there is never a commit and on_commit callbacks never run. Wrap the request in `with self.captureOnCommitCallbacks(execute=True):` so Django collects the callbacks and calls them as the block exits, then assert on `mail.outbox` after the block.
- How do you check the HTML version of the welcome email?A message sent with `html_message=` or built as `EmailMultiAlternatives` carries its HTML part in `.alternatives`, a list of named tuples with `content` and `mimetype`. Assert that one alternative has `mimetype` `'text/html'` and that its `content` contains the fragment you care about.
- Why read mail.outbox instead of importing outbox from django.core.mail?Django assigns a new list to `mail.outbox` at the start of each test. A name imported with `from django.core.mail import outbox` still points at the old list, so it shows stale messages or stays empty. Reading the attribute through the module always gets the current list.
saying these in an interview costs you the question
- You must set the locmem backend in settings.py before tests can check email
- mail.outbox accumulates across tests, so compare counts before and after
- from django.core.mail import outbox is equivalent to reading mail.outbox
- Tests send real email unless you mock send_mail
- Overriding DEFAULT_FROM_EMAIL also changes a sender captured at import time