В Laravel есть две разные задачи в тестировании почты, и их смешение — причина того, что набор тестов зелёный, а письмо в проде сломано.

  1. Код вообще вызвал отправку? Это юнит-тесты: Mail::fake() и проверки. Быстро, без SMTP и сети.
  2. Само письмо правильное? Это интеграция: нужен настоящий 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() вне продакшена — дешёвая страховка.

Что дальше