Если ваши тесты отправки писем идеально проходят на локальной машине, но разваливаются, как только попадают в CI, вы не одиноки. Обычная реакция — расставить вызовы sleep по всему тестовому коду или увеличить количество повторных попыток (retries), пока сборка не пройдет. Это может временно унять шум, но не исправит баг. Это лишь скроет его.
Настоящая проблема заключается в том, как ваш тест определяет, какое именно письмо нужно открыть.
Проблема общего почтового ящика
На локальной машине вы запускаете по одному тесту за раз. Приходит одно письмо. Вы его забираете. Все просто.
CI — это совершенно иная среда. Один pull request может запустить четыре, восемь или шестнадцать параллельных задач. Если все они используют один и тот же тестовый ящик — будь то сервер Mailosaur, ящик Mailtrap или реальный аккаунт на стейджинг-домене — они все одновременно пишут в одну и ту же «корзину». Задача А отправляет сброс пароля. Задача Б отправляет приглашение. Задача В повторяет неудачный сценарий приветствия. Тем временем фоновые воркеры и очереди доставки создают джиттер, который вы не можете контролировать.
Когда каждая задача лезет в этот общий ящик и запрашивает самое свежее сообщение с темой «Reset your password», начинается гонка. Тот тест, который успел первым, получает нужное письмо. Тест, который опоздал, кликает по ссылке, предназначенной для другой задачи, выполняет проверку не того контента и падает с ошибкой, которая выглядит как проблема со временем. Но это не проблема времени. Это проблема идентификации.
Почему подход «самое свежее сообщение» не работает
В эту хрупкую ловушку легко попасть, потому что она кажется интуитивно понятной:
- Запустить пользовательский сценарий.
- Опрашивать ящик каждые несколько секунд.
- Открыть самое последнее сообщение, соответствующее теме.
- Кликнуть по первой ссылке и выполнить проверки.
Эта схема рассыпается по нескольким причинам, помимо простого параллелизма. Повторная попытка (retry) от предыдущего неудачного запуска может прийти с опозданием, внезапно став самым новым сообщением именно в тот момент, когда ваш текущий тест делает опрос. Фоновые воркеры внутри вашего приложения могут поставить два письма в очередь и доставить второе раньше первого. Темы писем — слабый идентификатор; ваше стейджинг-приложение может отправлять похожие письма из разных сценариев. Сортировка по временной метке еще хуже, чем кажется, потому что расхождение часов (clock skew) между CI-раннером и почтовым провайдером — реальный фактор, а почтовые API часто кэшируют или группируют свои индексы.
В загруженных средах временные метки становятся ненадежными. Вам нужно что-то более прямое.
Что такое run-токен на самом деле
Run-токен — это не более чем уникальная строка, генерируемая в начале вашего теста и внедряемая в письмо, которое отправляет ваше приложение. Ему не обязательно быть видимым для пользователя или выглядеть элегантно. Он должен лишь гарантировать, что вы сможете доказать: это конкретное сообщение принадлежит именно этому конкретному запуску теста.
Лучше всего работают конкретные примеры. Перед началом теста сгенерируйте токен, например:
- UUID:
550e8400-e29b-41d4-a716-446655440001 - ID запроса в рамках сборки:
req_ci_build_4821_a7f3 - Slug приглашения или суффикс метаданных:
signup-token-8k2m9n - Случайная hex-строка, сгенерированная тест-раннером:
test-run-a4f9c2d1
Если вы контролируете код бэкенда, передайте токен в контекст письма и отобразите его где-нибудь в теле сообщения. Если вы тестируете приложение типа «черный ящик», проверьте, не принимает ли оно уже какое-то поле-ссылку, которое вы могли бы использовать. Если нет, иногда можно внедрить токен в локальную часть адреса получателя, используя плюс-адресацию — testuser+a4f9c2d1@example.com, — хотя это сработает только в том случае, если ваше приложение сохраняет его и возвращает обратно в письме.
Суть в том, чтобы перестать искать по метаданным, которыми владеет почтовая система. Ищите по данным, которыми владеет ваш тест.
Надежный паттерн
Замените алгоритм «самого свежего сообщения» на узкий поиск на основе токена:
- Сгенерируйте run-токен перед запуском любого сценария.
- Запустите действие пользователя, убедившись, что приложение включит токен в исходящее письмо.
- Опрашивайте почтового провайдера, используя фильтры, ограниченные этим токеном. Если API поддерживает поиск по телу письма, используйте его. Если нет, получите список подходящих сообщений и выполните
grepпо их телу на стороне клиента. - Убедитесь, что токен присутствует в теле сообщения, прежде чем взаимодействовать с любыми ссылками, кнопками или кодами подтверждения.
- Только после этого извлеките URL подтверждения или код и продолжайте.
Эта последовательность имеет значение. Если вы сначала извлечете ссылку, а затем проверите токен, вы уже кликнете не по тому письму. Ассерт — это ваш контролер.
Practically, your helper should look for Subject:"Welcome to AppName" AND Body:"a4f9c2d1" rather than Subject:"Welcome to AppName" sort:-received. Many mail testing services expose search APIs that accept body content filters. Use them. If you are working against a simpler provider, keep your polling logic in one place so you can add client-side filtering consistently across every test.
Three Rules to Keep the System Honest
A run token fixes selection, but you still need discipline around how you poll and what you do when things go wrong.
Log the inbox state on failure. When a test fails, output the inbox identifier, the subject line you queried, the exact timestamp window, and how many messages matched your criteria. This turns a vague "email not found" error into a concrete story. If job 7823 picked up a retry message from job 7821 because it arrived three seconds later, your logs should make that obvious. Without this context, you will blame timing and add another sleep.
Keep all email polling in one helper file. Do not scatter setTimeout and cy.task calls across twenty test files. Centralize the logic that waits for messages, retries the API call, and applies backoff. If every test uses the same helper, your filtering rules stay consistent, and when you improve the search logic, every test benefits. It also makes it easier to enforce the token check; if the helper requires a token argument, no one can accidentally fall back to the "latest message" crutch.
Watch your retries. Test retries are common in CI, but each retry creates another email in the inbox. If your test passes on attempt three, you might celebrate and move on. What you miss is that attempts one and two exposed a real bug—a race condition, a duplicate send, or a missing index—that extra messages masked. If you must use retries, check whether the inbox contains unexpected duplicates after a failure. Better yet, consider cleaning the inbox or using a unique address per job if your provider supports dynamic inboxes. Retries should not become a strategy for absorbing unreliable selection logic.
The Real Takeaway
Sorting an inbox by date and grabbing the top result is not testing. It is guessing dressed up in code. A run token costs almost nothing—one string variable, one extra filter parameter, maybe a small template change—and it gives your test deterministic identity. It proves the message in front of you belongs to the run you are executing right now.
Stop adding sleeps and hoping the network behaves. Generate a token, put it in the email, and search for it directly. Your CI runs will be faster, your logs will be readable, and you will finally trust what the email suite is telling you.
