Где тест на Playwright обычно останавливается
Большинство тестов регистрации заканчиваются на фразе «проверьте почту». Форма заполнена, кнопка нажата, страница сказала то, что нужно, — а всё, что происходит после этой фразы, просто принимается на веру. Ушло ли письмо, тот ли это код, которого ожидает сервер, открывает ли ссылка для подтверждения рабочую страницу — всё это остаётся на совести продакшена.
Остаётся оно там потому, что следующий шаг асинхронный и происходит за пределами браузера, а Playwright нечего там нажимать. Три обычных способа обойти это доказывают каждый своё:
- Мокирование отправителя писем
- Доказывает, что ваш код вызвал
send(). Не доказывает ничего насчёт шаблона, ссылки или провайдера, который мог отклонить письмо. - Локальный SMTP-приёмник (Mailpit, MailHog, smtp4dev)
- Доказывает, что из приложения вышло корректно сформированное письмо. Ещё один сервис в CI, а всё, что происходит только в открытом интернете, — настоящий поиск MX, настоящий провайдер, настоящий получатель, — здесь не происходит.
- Настоящий одноразовый ящик
- Доказывает, что письмо вышло из приложения, пересекло интернет, было принято настоящим почтовым сервером и несёт в себе код, который действительно работает. Единственная цена — тест должен уметь правильно ждать, а это и есть тема всего этого руководства.
За этим стоит API из трёх эндпоинтов без ключа, описанный в справочнике. Если хочется сначала разобраться с общей дисциплиной, а уже потом со спецификой Playwright, — в руководстве «Сквозное тестирование потока проверки» это раскрыто для любого раннера тестов; здесь же — версия для Playwright, с фикстурой, которая делает всё это приятным.
Фикстура, которая даёт каждому тесту собственный ящик
test.extend в Playwright — правильное место для этого: ящик становится тем, что тест запрашивает по имени, как page, а адрес каждый раз придумывается заново. На сервере ничего создавать не нужно — ящик появляется в момент, когда до него доходит письмо, — поэтому фикстура представляет собой класс со случайным адресом внутри и тремя небольшими методами.
import { test as base, expect } from '@playwright/test';
const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';
export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
id: string; from: string; to: string; subject: string; date: string;
text: string | null; html: string | null;
};
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
export class Inbox {
readonly address: string;
/** A mailbox nothing else in this run, or any previous run, is using. */
constructor(prefix = 'e2e') {
this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
}
/** Block until a matching message arrives, or the deadline passes. */
async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
while (Date.now() < deadline) {
const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.address)}`);
if (res.status === 429) { // slow down, do not fail
await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
continue;
}
if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${this.address}`);
const { messages } = (await res.json()) as { messages: Summary[] };
const hit = messages.find(m =>
(!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
(!opts.fromContains || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
if (hit) return this.read(hit.id);
await sleep(1000); // one read a second, never throttled
}
throw new Error(`no message for ${this.address} within the deadline`);
}
async read(id: string): Promise<Message> {
const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
return res.json() as Promise<Message>;
}
/** Optional: everything expires on its own after a few days. Idempotent. */
async delete(id: string): Promise<void> {
await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
}
}
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use) => {
await use(new Inbox());
},
});
export { expect };В этом файле две вещи сделаны намеренно. Адрес случаен для каждого теста, а не для файла или прогона, — поэтому параллельные воркеры никогда не смогут прочитать чужую почту. А waitFor возвращает письмо целиком, а не только сводку, — на практике дальше вам почти всегда нужно тело письма, и один лишний вызов в каждом тесте в сумме складывается в заметную экономию.
Ожидание письма без sleep
Почта не синхронна. Обычно она приходит за две-три секунды, а иногда — за двадцать, и то, как именно тест ждёт, определяет, можно ли доверять всему набору тестов. Правила короткие:
- Дедлайн, а не счётчик попыток.
for (let i = 0; i < 30; i++)— это тридцать попыток на той скорости, с которой случится выполняться циклу: короче, если API ускорится, длиннее, если ваш отправитель замедлится. Дедлайн по реальному времени означает одно и то же на любой машине. - Одно чтение в секунду. Это задокументированный ритм, и он никогда не ограничивается искусственно. Быстрее — получаете отказ с
429и заголовкомRetry-After, а опрос почаще всё равно не заставит письмо прийти быстрее. - Никакого
waitForTimeout. Фиксированный sleep в неудачный день оказывается слишком коротким, а во все остальные — слишком длинным. Цикл останавливается в тот момент, когда письмо появляется. - Фильтруйте — не берите самое новое письмо вслепую. Передавайте
subjectContainsилиfromContains. Когда сценарий отправляет два письма — приветственное и с кодом, — самое новое не всегда оказывается тем, что нужно.
Коды состояния, с которыми столкнётся цикл, и что делать с каждым из них:
| Код | Значит | Что делает цикл |
|---|---|---|
200 | Ящик прочитан. count может быть равен 0 — пустой ящик никогда не даёт 404. | Ищите совпадение; если его нет, засните на секунду и повторите. |
400 | Адрес сформирован неверно. | Бросайте исключение. Повтор не исправит опечатку. |
404 | Этот домен здесь не обслуживается. | Бросайте исключение и проверьте MX-запись, если это ваш собственный домен. |
429 | Больше одного чтения в секунду для этого адреса, либо больше 1200 запросов в минуту с этого раннера. | Засните на число секунд из Retry-After и продолжайте. Никогда не проваливайте тест из-за 429. |
Как достать код или ссылку из письма
Письмо возвращается с обеими частями, а какую именно разбирать — зависит от того, что отправляет ваше приложение:
text- Часть с обычным текстом. Разбирайте её, если она есть, — никакой разметки, а шестизначный код — это просто шестизначный код.
html- HTML-часть, или
null, если отправитель прислал только текст. Ссылки для подтверждения часто живут только здесь, внутри<a href>, причём&записан как&.
import type { Message } from './fixtures';
/** The whole body, both parts, with HTML entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&/g, '&');
/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
const hit = bodyOf(m).match(pattern);
if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
return hit[1];
}
/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
const re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
const hit = bodyOf(m).match(re);
if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
return hit[0];
}Шаблон намеренно привязан к формулировке именно вашего письма. Один лишь [0-9]{6} с готовностью совпадёт с годом, ценой или номером заказа, который просто оказался первым; code is ([0-9]{6}) совпадает только с вашим кодом и ничем больше — и в тот день, когда кто-то переформулирует письмо, тест провалится и сообщит об этом, вместо того чтобы пройти по неверному числу.
Ссылки сверяются по известному фрагменту пути — /confirm/, /reset/, — а не по принципу «первый попавшийся URL», потому что в транзакционном письме их обычно штук пять: логотип, отписка, справочный центр, значок магазина приложений и та единственная, что нужна вам.
Три сценария целиком
Когда фикстура и функции извлечения уже готовы, каждый тест читается так же, как читается сама тестируемая функциональность. Ожидание, опрос и разбор письма вынесены в другое место — собственно, ради этого их туда и выносили.
Регистрация с кодом подтверждения
import { test, expect } from './fixtures';
import { codeFrom } from './extract';
test('a new account confirms its email address', async ({ page, inbox }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(inbox.address);
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Check your inbox')).toBeVisible();
const message = await inbox.waitFor({ subjectContains: 'confirm' });
await page.getByLabel('Confirmation code').fill(codeFrom(message));
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});Magic-ссылка, которая сама авторизует пользователя
Печатать ничего не нужно: тест переходит по ссылке из письма и проверяет, куда она приводит.
import { test, expect } from './fixtures';
import { linkFrom } from './extract';
test('a magic link signs the user in', async ({ page, inbox }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Email me a link' }).click();
const message = await inbox.waitFor({ subjectContains: 'sign in' });
await page.goto(linkFrom(message, '/auth/magic/'));
await expect(page).toHaveURL(/\/dashboard/);
});Сброс пароля, а затем вход с новым паролем
Для теста сброса пароля нужен уже существующий пользователь, а это задача для собственного тестового «шва» приложения — внутреннего эндпоинта, фикстуры базы данных, CLI, — а не для браузера. Дальше сценарий устроен так же, как остальные: запрос, ожидание, переход, проверка.
import { test, expect } from './fixtures';
import { linkFrom } from './extract';
test('a password reset link changes the password', async ({ page, inbox, request }) => {
// Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });
await page.goto('/forgot-password');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Send reset link' }).click();
const message = await inbox.waitFor({ subjectContains: 'reset' });
await page.goto(linkFrom(message, '/reset/'));
await page.getByLabel('New password').fill('new-password-2');
await page.getByRole('button', { name: 'Change password' }).click();
await page.goto('/login');
await page.getByLabel('Email').fill(inbox.address);
await page.getByLabel('Password').fill('new-password-2');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});Как заставить это пережить CI
Всё описанное выше работает на ноутбуке. А вот что ломается только тогда, когда это запускается по двадцать раз в день на чужой машине.
| Симптом | Причина | Решение |
|---|---|---|
| Проходит локально, падает в CI | Раннер не может достучаться до открытого интернета, либо исходящий трафик фильтруется. | Разрешите grabmail.io по HTTPS. Больше ничего — ни SMTP-порта, ни входящих соединений. |
| Падает с первого раза, проходит при повторе | Ваш отправитель ставит письма в очередь, а дедлайн короче, чем эта очередь. | Прежде чем трогать что-либо ещё, увеличьте дедлайн. Шестьдесят секунд — разумный потолок для транзакционного письма. |
Всплески 429 | Несколько тестов опрашивают один адрес, либо весь раннер превысил 1200 запросов в минуту. | Один адрес на тест — этим и занимается фикстура. Потолок на клиента — двадцать ящиков, опрашиваемых раз в секунду. |
| Зелёная сборка, сломанная функциональность | Переиспользованный адрес отдал старое письмо. | Случайный адрес на каждый тест. Вот это действительно важно. |
| Нестабильность только при нескольких воркерах | Два теста делят один ящик, либо проверка полагается на то, какое письмо новее. | Новый адрес на каждый тест и фильтр subjectContains; никогда не берите самое новое письмо вслепую. |
| Работает неделю, а потом никогда | Фикстура закэшировала id письма; здесь всё удаляется через 5 дней. | Тесты должны сами инициировать своё письмо при каждом прогоне. Ничто здесь не переживает 5 дней. |
Хранить секрет не нужно. Публичные домены не требуют ни ключа, ни аккаунта, ни заголовка — если вашему пайплайну для запуска этих тестов нужны учётные данные, значит, что-то понято неверно. Единственная настройка, которую стоит прописать явно, — это таймаут: именно в нём значения Playwright по умолчанию не подходят для теста, ожидающего почту.
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
timeout: 120_000,
expect: { timeout: 10_000 },
fullyParallel: true, // safe: every test has its own inbox
});Workflow для GitHub Actions, который запускает этот набор тестов, с уже решёнными вопросами дедлайна и исходящего трафика, целиком приведён в руководстве по CI.
Если ваше приложение отказывает одноразовым доменам
Некоторые формы регистрации сверяют адрес с публичными списками одноразовых доменов и отказывают grabmail.io сразу же. Это особенность вашего приложения, а не недостаток теста, — и решение не в том, чтобы ослаблять проверку для тестового окружения. Вместо этого направьте на сервис собственный домен: одна MX-запись, без аккаунта, и любой адрес на нём становится ящиком, который та же фикстура может прочитать после изменения одной константы.
«Как превратить домен в catch-all-ящик» — это настройка; «Неограниченные тестовые аккаунты на одном домене» — как это выглядит в наборе тестов.
Прежде чем считать задачу закрытой
- Свой адрес на каждый тест, из фикстуры, — никогда не константа.
- Дедлайн по реальному времени, а при провале — сообщение с адресом, который ждали.
429обрабатывается через сон на времяRetry-After, а не провалом теста.- Код или ссылка сверяются с формулировкой вашего письма, а не с голым шаблоном.
- Таймаут теста с запасом превышает дедлайн ожидания почты.
- Фильтр по теме или отправителю — чтобы при двух письмах побеждало нужное.
- Никаких проверок того, насколько быстро пришла почта, — только того, что она пришла.
Вот и вся дисциплина. Всё остальное в тестировании почты на Playwright ничем не отличается от тестирования любой другой асинхронности. Тот же помощник в виде команд Cypress — в руководстве по Cypress; а правила извлечения сами по себе, для любого раннера, — в «OTP-коды в автотестах».
Вопросы
Нужен ли API-ключ, чтобы читать ящик из Playwright?
Нет. Публичные домены не требуют ни ключа, ни аккаунта, ни заголовка. Заголовок Authorization: Bearer нужен только для платного пула доменов, которые держат подальше от чёрных списков одноразовой почты, а это отдельный продукт.
Могут ли тесты выполняться в параллельных воркерах?
Да, и именно в этом смысл случайного адреса на каждый тест: два воркера никогда не смогут прочитать чужую почту. Потолок на клиента — 1200 запросов в минуту, то есть двадцать ящиков, опрашиваемых раз в секунду, — с запасом для набора тестов, а фикстура и так никогда не опрашивает чаще раза в секунду.
Стоит ли использовать фикстуру request из Playwright вместо fetch?
Подойдёт и то, и другое. Здесь используется fetch, потому что тогда этот помощник без изменений запускается и в обычном Node-скрипте, и в глобальной настройке, и в другом раннере. request из Playwright добавляет трассировку вызовов, что имеет смысл, если вы хотите видеть опрос в trace viewer.
Что если письмо приходит раньше, чем тест начинает опрос?
Ничего не меняется. Первый же опрос его вернёт. Ящик хранит всё, что пришло, в течение 5 дней независимо от того, читает ли его кто-то, поэтому письмо, пришедшее прямо во время клика, просто будет на месте при следующем запросе.
Приватен ли ящик, пока им пользуется тест?
Нет. Прочитать его может любой, кто знает адрес, — и на публичном домене, и на вашем собственном. Для случайного адреса, который существует одиннадцать секунд и хранит один одноразовый код, это не имеет значения; а для staging-окружения, отправляющего настоящую почту клиентам, это неприемлемо — не направляйте такое сюда.
Как убрать за собой после теста?
По желанию — вызовом DELETE для письма, который идемпотентен. В любом случае всё истекает через 5 дней, так что прогон без уборки ничего не стоит — удаление лишь облегчает чтение следующего провала.


