Тестирование и CI

Тесты почты в Playwright: читаем код из реального ящика

Тест на Playwright может заполнить форму регистрации за две секунды, а дальше не имеет ни малейшего представления, что произошло, — потому что следующий шаг это письмо. Вот фикстура, которая его читает: настоящее письмо, из настоящего ящика, без API-ключа, — и три привычки, которые не дают тесту стать нестабильным.

  • Средний
  • 20 мин на чтение
Серое окно браузера с синей стрелкой курсора, синий конверт, входящий в прорезь сбоку, и серый секундомер впереди

Где тест на Playwright обычно останавливается

Большинство тестов регистрации заканчиваются на фразе «проверьте почту». Форма заполнена, кнопка нажата, страница сказала то, что нужно, — а всё, что происходит после этой фразы, просто принимается на веру. Ушло ли письмо, тот ли это код, которого ожидает сервер, открывает ли ссылка для подтверждения рабочую страницу — всё это остаётся на совести продакшена.

Остаётся оно там потому, что следующий шаг асинхронный и происходит за пределами браузера, а Playwright нечего там нажимать. Три обычных способа обойти это доказывают каждый своё:

Мокирование отправителя писем
Доказывает, что ваш код вызвал send(). Не доказывает ничего насчёт шаблона, ссылки или провайдера, который мог отклонить письмо.
Локальный SMTP-приёмник (Mailpit, MailHog, smtp4dev)
Доказывает, что из приложения вышло корректно сформированное письмо. Ещё один сервис в CI, а всё, что происходит только в открытом интернете, — настоящий поиск MX, настоящий провайдер, настоящий получатель, — здесь не происходит.
Настоящий одноразовый ящик
Доказывает, что письмо вышло из приложения, пересекло интернет, было принято настоящим почтовым сервером и несёт в себе код, который действительно работает. Единственная цена — тест должен уметь правильно ждать, а это и есть тема всего этого руководства.

За этим стоит API из трёх эндпоинтов без ключа, описанный в справочнике. Если хочется сначала разобраться с общей дисциплиной, а уже потом со спецификой Playwright, — в руководстве «Сквозное тестирование потока проверки» это раскрыто для любого раннера тестов; здесь же — версия для Playwright, с фикстурой, которая делает всё это приятным.

Фикстура, которая даёт каждому тесту собственный ящик

test.extend в Playwright — правильное место для этого: ящик становится тем, что тест запрашивает по имени, как page, а адрес каждый раз придумывается заново. На сервере ничего создавать не нужно — ящик появляется в момент, когда до него доходит письмо, — поэтому фикстура представляет собой класс со случайным адресом внутри и тремя небольшими методами.

tests/fixtures.ts
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>, причём & записан как &amp;.
tests/extract.ts
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(/&amp;/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», потому что в транзакционном письме их обычно штук пять: логотип, отписка, справочный центр, значок магазина приложений и та единственная, что нужна вам.

Три сценария целиком

Когда фикстура и функции извлечения уже готовы, каждый тест читается так же, как читается сама тестируемая функциональность. Ожидание, опрос и разбор письма вынесены в другое место — собственно, ради этого их туда и выносили.

Регистрация с кодом подтверждения

tests/signup.spec.ts
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-ссылка, которая сама авторизует пользователя

Печатать ничего не нужно: тест переходит по ссылке из письма и проверяет, куда она приводит.

tests/magic-link.spec.ts
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, — а не для браузера. Дальше сценарий устроен так же, как остальные: запрос, ожидание, переход, проверка.

tests/password-reset.spec.ts
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
// 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 дней, так что прогон без уборки ничего не стоит — удаление лишь облегчает чтение следующего провала.

Читать дальше

Попробуйте, пока свежо

Адрес — это один клик, без аккаунта и карты. Всё из этого руководства сразу заработает на нём.

С возвращением

Ваши ящики и ваши домены в одном месте.