Почему Cypress нужна для этого задача
Тест Cypress выполняется внутри браузера, в том же окне, что и тестируемая страница. Именно поэтому cy.get и cy.contains такие прямолинейные — и именно поэтому тест не может просто крутить цикл по HTTP API целую минуту: очередь команд — не место для цикла while со sleep внутри, а цепочка повторяемых вызовов cy.request тяжело читается и ещё тяжелее останавливается.
Три обычных способа протестировать почтовую половину сценария доказывают каждый своё, и только один из них доказывает то, что вы на самом деле выкатили в продакшен:
- Стаб отправителя писем
- Доказывает, что
send()был вызван с правильными аргументами. Не говорит ничего насчёт шаблона, ссылки или провайдера, который мог отклонить письмо. - Локальный SMTP-приёмник (Mailpit, MailHog, smtp4dev)
- Доказывает, что из приложения вышло корректно сформированное письмо. Ещё один контейнер в CI, а всё, что происходит только в открытом интернете, — настоящий поиск MX, настоящий провайдер, настоящий получатель, — здесь не происходит.
- Настоящий одноразовый ящик
- Доказывает, что письмо вышло из приложения, пересекло интернет, было принято настоящим почтовым сервером и несёт в себе код, который действительно работает. Цена в том, что тест должен уметь правильно ждать, а в Cypress правильное место для ожидания — задача.
API, который вызывает задача, — это три эндпоинта без ключа, справочник короткий. Версия этой дисциплины, не привязанная к конкретному раннеру, — в руководстве «Сквозное тестирование потока проверки»; версия для Playwright, с фикстурой вместо задачи, — в руководстве по Playwright.
Задача: цикл опроса на стороне Node
Всё, что должно ждать, живёт здесь, в setupNodeEvents. Это обычный Node: fetch, дедлайн, одно чтение в секунду и ветка для 429, которая засыпает, а не падает. Тест ничего этого не видит.
import { defineConfig } from 'cypress';
const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
type Args = { address: string; subjectContains?: string; fromContains?: string; timeoutMs?: number };
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
taskTimeout: 90_000, // above the mail deadline below, always
setupNodeEvents(on) {
on('task', {
/** Poll a mailbox until a matching message arrives, or the deadline passes. */
async waitForMail({ address, subjectContains, fromContains, timeoutMs = 60_000 }: Args) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(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 ${address}`);
const { messages } = (await res.json()) as { messages: { id: string; from: string; subject: string }[] };
const hit = messages.find(m =>
(!subjectContains || m.subject.toLowerCase().includes(subjectContains.toLowerCase())) &&
(!fromContains || m.from.toLowerCase().includes(fromContains.toLowerCase())));
if (hit) {
const full = await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`);
if (!full.ok) throw new Error(`GET /message answered ${full.status}`);
return full.json(); // the whole message, both parts
}
await sleep(1000); // one read a second, never throttled
}
return null; // "not yet" is an answer, not an error
},
});
},
},
});В этом файле стоит отдельно отметить два решения. Задача возвращает полное письмо, а не сводку, — потому что дальше каждому тесту почти всегда нужно тело письма, и второй вызов задачи ради этого был бы лишним шумом. А по истечении дедлайна она возвращает null вместо того, чтобы бросать исключение: «письма пока нет» — вполне законный ответ для задачи, и уже команда ниже превращает его в провал с полезным сообщением.
Две пользовательские команды и две функции извлечения
Команды намеренно тонкие. freshAddress придумывает ящик; waitForMail вызывает задачу с таймаутом, заметно превышающим дедлайн, и проверяет ответ. Функции извлечения — обычные функции, потому что это обычная работа со строками, а команда Cypress только усложнила бы их модульное тестирование.
export type Message = {
id: string; from: string; to: string; subject: string; date: string;
text: string | null; html: string | null;
};
type WaitOpts = { subjectContains?: string; fromContains?: string; timeoutMs?: number };
declare global {
namespace Cypress {
interface Chainable {
/** A mailbox nothing else in this run, or any previous run, is using. */
freshAddress(prefix?: string): Chainable<string>;
/** Block until a matching message arrives. Fails the test at the deadline. */
waitForMail(address: string, opts?: WaitOpts): Chainable<Message>;
}
}
}
Cypress.Commands.add('freshAddress', (prefix = 'cy') =>
cy.wrap(`${prefix}-${Math.random().toString(36).slice(2, 10)}@grabmail.io`, { log: false }));
Cypress.Commands.add('waitForMail', (address, opts = {}) =>
cy.task<Message | null>('waitForMail', { address, ...opts }, { timeout: (opts.timeoutMs ?? 60_000) + 10_000 })
.then(m => {
expect(m, `a message for ${address}`).not.to.be.null;
return cy.wrap(m as Message, { log: false });
}));
/** The whole body, both parts, with the 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 hit = bodyOf(m).match(new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`));
if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
return hit[0];
}Обратите внимание на собственный timeout команды: это дедлайн задачи плюс десять секунд, — так задаче всегда хватает времени дать ответ. Без этого таймаут задачи по умолчанию в Cypress, шестьдесят секунд, соревнуется с шестидесятисекундным дедлайном ожидания почты и выигрывает на несколько миллисекунд, а в провале обвиняют задачу.
Три теста целиком
Когда задача и команды уже готовы, каждый тест читается так же, как читается сама тестируемая функциональность. Ожидание и разбор письма вынесены в другое место — собственно, в этом и был смысл.
Регистрация с кодом подтверждения
import { codeFrom } from '../support/commands';
describe('sign-up', () => {
it('confirms the address with the emailed code', () => {
cy.freshAddress().then(address => {
cy.visit('/signup');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('correct-horse-battery-staple');
cy.contains('button', 'Create account').click();
cy.contains('Check your inbox').should('be.visible');
cy.waitForMail(address, { subjectContains: 'confirm' }).then(message => {
cy.get('input[name="code"]').type(codeFrom(message));
cy.contains('button', 'Confirm').click();
cy.contains('h1', 'Welcome').should('be.visible');
});
});
});
});Вход, который запрашивает одноразовый код по почте
Пользователь должен уже существовать, а это задача для собственного тестового «шва» приложения — внутреннего эндпоинта, фикстуры базы данных, CLI, — до которого добираются через cy.request, а не через браузер.
import { codeFrom } from '../support/commands';
describe('login with an emailed one-time code', () => {
it('asks for the code and accepts it', () => {
cy.freshAddress().then(address => {
// Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
cy.request('POST', '/internal/test/users', { email: address, password: 'hunter2hunter2', otpByEmail: true });
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('hunter2hunter2');
cy.contains('button', 'Log in').click();
cy.contains('Enter the code we emailed you').should('be.visible');
cy.waitForMail(address, { subjectContains: 'code' }).then(message => {
cy.get('input[name="otp"]').type(codeFrom(message, /code is\s*([0-9]{6})/i));
cy.contains('button', 'Continue').click();
cy.url().should('include', '/dashboard');
});
});
});
});Сброс пароля, а затем вход с новым паролем
По ссылке сброса переходят обычным cy.visit, если она указывает на тот же origin, что и baseUrl. Если ваше приложение отправляет пользователей на страницу сброса на другом origin — скажем, на поддомене авторизации, — оберните шаги на этой странице в cy.origin(); извлечение ссылки при этом не меняется.
import { linkFrom } from '../support/commands';
describe('password reset', () => {
it('changes the password through the emailed link', () => {
cy.freshAddress().then(address => {
cy.request('POST', '/internal/test/users', { email: address, password: 'old-password-1' });
cy.visit('/forgot-password');
cy.get('input[name="email"]').type(address);
cy.contains('button', 'Send reset link').click();
cy.waitForMail(address, { subjectContains: 'reset' }).then(message => {
cy.visit(linkFrom(message, '/reset/')); // same origin as baseUrl: a plain visit
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Change password').click();
});
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Log in').click();
cy.url().should('include', '/dashboard');
});
});
});Как заставить это пережить CI
Всё описанное выше работает на ноутбуке. А вот что ломается только тогда, когда это запускается по двадцать раз в день на чужой машине.
| Симптом | Причина | Решение |
|---|---|---|
| Проходит локально, падает в CI | Раннер не может достучаться до открытого интернета, либо исходящий трафик фильтруется. | Разрешите grabmail.io по HTTPS со стороны Node. Больше ничего — ни SMTP-порта, ни входящих соединений. |
| «cy.task timed out» без единого слова про почту | taskTimeout (по умолчанию 60 секунд) меньше дедлайна ожидания почты. | Установите в конфиге taskTimeout выше дедлайна и передавайте таймаут на вызов, который команда уже вычисляет сама. |
| Падает с первого раза, проходит при повторе | Ваш отправитель ставит письма в очередь, а дедлайн короче, чем эта очередь. | Прежде чем трогать что-либо ещё, увеличьте дедлайн. Шестьдесят секунд — разумный потолок для транзакционного письма. |
Всплески 429 | Несколько тестов опрашивают один адрес, либо раннер превысил 1200 запросов в минуту. | Один адрес на тест. Потолок на клиента — двадцать ящиков, опрашиваемых раз в секунду. |
| Зелёная сборка, сломанная функциональность | Переиспользованный адрес отдал старое письмо. | freshAddress в теле каждого теста. Вот это действительно важно. |
| Работает неделю, а потом никогда | Фикстура закэшировала id письма; здесь всё удаляется через 5 дней. | Тесты должны сами инициировать своё письмо при каждом прогоне. Ничто здесь не переживает 5 дней. |
Хранить секрет не нужно: публичные домены не требуют ни ключа, ни аккаунта, ни заголовка. Если вашему пайплайну для запуска этих тестов нужны учётные данные, значит, что-то понято неверно. Workflow для GitHub Actions, который запускает подобный набор тестов, с уже решённым вопросом исходящего трафика, — в руководстве по CI.
Если ваше приложение отказывает одноразовым доменам
Некоторые формы регистрации сверяют адрес с публичными списками одноразовых доменов и отказывают grabmail.io сразу же. Это особенность вашего приложения, а не недостаток теста, — и решение не в том, чтобы ослаблять проверку для тестового окружения. Вместо этого направьте на сервис собственный домен: одна MX-запись, без аккаунта, и любой адрес на нём становится ящиком, который та же задача может прочитать после изменения одной константы.
«Как превратить домен в catch-all-ящик» — это настройка; «Неограниченные тестовые аккаунты на одном домене» — как это выглядит в наборе тестов.
Прежде чем считать задачу закрытой
- Свой адрес на каждый тест, придуманный в теле теста, — никогда не константа.
- Ожидание в задаче с дедлайном по реальному времени; по истечении дедлайна —
null, никогда неundefined. - И
taskTimeout, и таймаут команды — оба выше дедлайна ожидания почты. 429обрабатывается через сон на времяRetry-After, а не провалом теста.- Код или ссылка сверяются с формулировкой вашего письма, а не с голым шаблоном.
- Фильтр по теме или отправителю — чтобы при двух письмах побеждало нужное.
- Никаких проверок того, насколько быстро пришла почта, — только того, что она пришла.
Вот и вся дисциплина. Правила извлечения сами по себе, для любого раннера, — в «OTP-коды в автотестах».
Вопросы
Можно ли использовать cy.request в цикле вместо задачи?
Можно: cy.request тоже выполняется на стороне Node, поэтому CORS ему не помеха, и рекурсивная функция, которая повторяет запрос до совпадения или дедлайна, будет работать. Просто это тяжелее читать и тяжелее остановить, чем задачу с циклом while внутри, а задача к тому же избавляет тест от логики повторов.
Нужен ли API-ключ или переменная окружения Cypress?
Нет. Публичные домены не требуют ни ключа, ни аккаунта, ни заголовка, так что в cypress.env.json или в секреты CI класть нечего. Bearer-токен используется только для платного пула доменов, которые держат подальше от чёрных списков одноразовой почты, а это отдельный продукт.
Работает ли это с повторами тестов и параллелизацией Cypress?
Да, именно потому, что адрес придумывается прямо в теле теста: каждый повтор и каждая параллельная машина получают собственный ящик. Потолок в 1200 запросов в минуту на клиента — это двадцать ящиков, опрашиваемых раз в секунду, и прогон Cypress к этому значению даже не приближается.
Что если письмо приходит раньше, чем задача начинает опрос?
Ничего не меняется. Первый же опрос его вернёт. Ящик хранит всё, что пришло, в течение 5 дней независимо от того, читает ли его кто-то, поэтому письмо, пришедшее прямо во время клика, просто будет на месте при следующем запросе.
Приватен ли ящик, пока им пользуется тест?
Нет. Прочитать его может любой, кто знает адрес, — и на публичном домене, и на вашем собственном. Для случайного адреса, который существует одиннадцать секунд и хранит один одноразовый код, это не имеет значения; а для staging-окружения, отправляющего настоящую почту клиентам, это неприемлемо — не направляйте такое сюда.
Как убрать за собой после теста?
По желанию — вызовом DELETE для письма из задачи, который идемпотентен. В любом случае всё истекает через 5 дней, так что прогон без уборки ничего не стоит — удаление лишь облегчает чтение следующего провала.


