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

Тесты почты в Cypress: регистрация, OTP и сброс пароля

Cypress выполняет тест в браузере, а браузер не умеет читать почтовый ящик. Обычное решение — задача на стороне Node, которая опрашивает ящик; вот она, работающая с настоящим одноразовым ящиком без API-ключа, — плюс две пользовательские команды, благодаря которым тест регистрации читается так же, как читается сама тестируемая функциональность.

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

Почему 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, которая засыпает, а не падает. Тест ничего этого не видит.

cypress.config.ts
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 только усложнила бы их модульное тестирование.

cypress/support/commands.ts
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(/&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 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, шестьдесят секунд, соревнуется с шестидесятисекундным дедлайном ожидания почты и выигрывает на несколько миллисекунд, а в провале обвиняют задачу.

Три теста целиком

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

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

cypress/e2e/signup.cy.ts
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, а не через браузер.

cypress/e2e/otp-login.cy.ts
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(); извлечение ссылки при этом не меняется.

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

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

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

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

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

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