В Laravel есть две разные задачи в тестировании почты, и их смешение — причина того, что набор тестов зелёный, а письмо в проде сломано.
- Код вообще вызвал отправку? Это юнит-тесты:
Mail::fake()и проверки. Быстро, без SMTP и сети. - Само письмо правильное? Это интеграция: нужен настоящий SMTP-эндпоинт и реальный MIME — кодировки, заголовки, HTML-часть, текстовая часть, вложения.
Обе задачи закрываются без коммерческой песочницы. Ниже — в том порядке, в котором их обычно внедряют.
1. Mail::fake() — доказать намерение
use Illuminate\Support\Facades\Mail;
it('emails the invoice to the customer', function () {
Mail::fake();
$this->post('/invoices/42/send');
Mail::assertSent(InvoiceMail::class, function ($mail) {
return $mail->hasTo('customer@example.com')
&& $mail->invoice->id === 42;
});
});
Что это доказывает: мейлер вызван, mailable правильный, получатель и данные верные. Чего это не доказывает: ничего о самом письме. Mail::fake() не рендерит mailable, поэтому сломанный Blade-шаблон, отсутствующая текстовая версия, неверный путь к вложению или ошибка кодировки такой тест проходят спокойно.
Полезно ещё проверять Mail::assertNothingSent() в сценариях, которые должны молчать, и Mail::assertQueued(...), если письмо ставится в очередь: assertSent его не увидит.
2. Отправить всё на один адрес
Иногда само письмо неважно — важно, что оно не уйдёт реальным людям. Laravel умеет перехватить всё исходящее на уровне фреймворка:
// App\Providers\AppServiceProvider::boot()
use Illuminate\Support\Facades\Mail;
if (! app()->isProduction()) {
Mail::alwaysTo(config('mail.test_address'));
}
Теперь любое письмо в локальном и staging-окружении идёт на один адрес, что бы ни было в коде фичи. Это страховка от классической аварии: staging, скопировавший продовый конфиг.
Отличие от песочницы: alwaysTo() меняет получателя, но письмо всё равно отправляет — это правильный инструмент для staging (где нужна настоящая доставка) и неправильный для нагрузочного прогона.
3. Настоящая ловушка для самого письма
Чтобы увидеть, что именно строит приложение, направьте его на SMTP-песочницу: Mailpit в Docker Compose или хостируемую. Конфигурация не зависит от инструмента:
# .env / .env.testing
MAIL_MAILER=smtp
MAIL_HOST=mailpit # или хост песочницы
MAIL_PORT=1025 # или порт песочницы
MAIL_USERNAME=anything
MAIL_PASSWORD=anything
MAIL_ENCRYPTION=null
Появляются две вещи, которых подмена не даёт: человек смотрит на отрендеренное письмо (HTML с заблокированными картинками, текстовая версия, ссылки), а тест проверяет байты, которые произвело приложение. Именно это ловит ошибки, доходящие до клиентов.
В Laravel есть ещё транспорт array (MAIL_MAILER=array): он держит отправленные письма в памяти процесса вместо доставки. Хорошая середина, когда нужно, чтобы фреймворк отрендерил настоящий mailable, но зависеть от сервиса не хочется — точный API смотрите в документации своей версии.
Что что ловит
| Баг | Mail::fake() |
alwaysTo() |
Настоящая ловушка |
|---|---|---|---|
| Мейлер вообще не вызван | да | нет | нет |
| Неверный получатель или mailable | да | частично | да |
| Сломанный Blade, нет текстовой части | нет | только если смотрит человек | да |
| Кодировки, битое вложение | нет | нет | да |
Ссылка ведёт на localhost |
нет | нет | да |
Проверки на настоящем письме
Три способа, от простого к точному:
- Assertable-хелперы. В свежих версиях Laravel в замыкание
Mail::assertSent()можно проверять отрендеренные тему, HTML и текст — сверьтесь с документацией своей версии, набор хелперов растёт от релиза к релизу. - API песочницы. У Mailpit есть REST API, поэтому тест может спросить, что пришло, и проверить тему, получателей, заголовки или тело. Это ближе всего к «сквозному» тесту: письмо реально прошло по SMTP.
- JSON-экспорт песочницы. Read-only ссылка этого инстанса отдаёт JSON, поэтому в пайплайне достаточно
curlиjq:
curl -fsS "$SANDBOX_EXPORT_URL" -o captured.json
jq -e '(.items | last | .subject) == "Your invoice #1042"' captured.json
Последний вариант работает на любом языке пайплайна, не только на PHP.
Частые ошибки
- Сравнивать письмо целиком по HTML. Снапшот ломается от любой правки дизайна — проверяйте тему, получателей и две-три ключевые строки.
- Забыть про очередь.
Mail::assertSent()не видит письма в очереди: нуженassertQueued()или явная подмена очереди. - Тестировать только «счастливый» шаблон. Отправляйте те, что ломаются в проде: сброс пароля, счёт с PDF, дайджест с сотней ссылок, всё с не-ASCII символами в теме.
- Дать тестовому окружению доступ к боевому релею. Одного скопированного
.envдостаточно, чтобы тесты разослали письма клиентам;alwaysTo()вне продакшена — дешёвая страховка.
Что дальше
- Как тестировать отправку писем, ничего не отправляя — четыре шага и раздел про CI.
- SMTP-сервер для тестов в Docker Compose — контейнерная часть этой статьи.
- Пусть ИИ-ассистент проверит ваши письма — JSON-экспорт из примеров выше, подробно.
- Mailpit против MailHog и Mailtrap — как выбрать ловушку.