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

OTP-коды из писем в автотестах — без флаки-тестов

Одноразовый код — это шесть цифр внутри письма, где кроме них есть ещё и год, цена, номер заказа и номер телефона. Доставать именно нужные шесть — каждый раз, в любом раннере — небольшая дисциплина: привязывать шаблон к формулировке, убирать разметку, игнорировать письмо, которое уже видели, уважать срок действия кода. Вот эта дисциплина, с готовым кодом для shell, Python и TypeScript.

  • Средний
  • 14 мин на чтение
Открытый синий конверт, перед ним в ряд шесть маленьких серых кубиков с синей точкой на каждом, под серой лупой

Где на самом деле находится код

В письме с подтверждением код может находиться в одном из трёх мест, и от того, какое из них вы читаете, зависит всё, что происходит дальше. JSON письма из API отдаёт все три сразу: subject, text (часть с обычным текстом, либо null) и html (HTML-часть, либо null).

ГдеКак это выглядитКак это читать
Часть с обычным текстом (text)Your code is 481920. It expires in 10 minutes.Разбирайте её в первую очередь, если она есть. Никакой разметки, нечего декодировать, а формулировка стабильна.
HTML-часть (html)То же самое предложение внутри таблицы, часто с цифрами, оформленными по одной на ячейку, и с каждым &, записанным как сущность.Замените теги на пробелы, раскодируйте сущности, схлопните пробелы, и только потом применяйте шаблон. Никогда не применяйте регулярное выражение к «сырому» HTML.
Тема письма481920 is your verification codeПодарок, если отправитель так делает: разбирать тело письма вообще не нужно. Сверяйтесь с темой, а тело используйте как запасной вариант.
ИзображениеКод, нарисованный как картинка, специально для того, чтобы обмануть именно такие скрипты.Редкость, и признак того, что отправитель не хочет автоматизации. Если шаблон ваш — измените его; если нет — честного обходного пути не существует.

Часть с обычным текстом — предпочтительный вариант, и большинство систем шаблонизации генерируют её автоматически из HTML, так что обычно она на месте. Когда она равна null, единственным телом письма остаётся HTML-часть, и следующие два раздела — о том, как читать её безопасно.

Привязывайте шаблон к формулировке вашего письма

Первый инстинкт — написать \d{6}. Он совпадёт с кодом, а заодно и с годом в подвале письма, почтовым индексом в блоке адреса, последними шестью цифрами номера телефона и номером заказа, который стоит двумя строками выше кода. Побеждает тот, что встретился первым, и тест с полной уверенностью вводит его в форму.

ШаблонТакже совпадает сВердикт
\d{6}Годами, почтовыми индексами, ценами без разделителя, номерами заказов, номерами телефонов, трек-номерами.Никогда. Это не шаблон, а подбрасывание монеты.
\b\d{6}\bВсё то же самое, что случайно оказалось ровно шестью цифрами с пробелом по обе стороны, — то есть почти всё.Едва ли лучше. Границы слова не знают, что такое код.
code is\D{0,12}(\d{6})Только шесть цифр, следующих за словами, которые ваш шаблон ставит перед кодом, — с запасом на двоеточие, пробел или остатки пробелов от вырезанного тега.Да. Совпадает только с кодом и ничем больше, и провалится в тот день, когда кто-то переформулирует письмо, — а именно об этом провале и стоит узнать.

\D{0,12} — это практическая деталь: после замены тегов на пробелы слова и цифры может разделять двоеточие, вереница пробелов или остатки тега <strong>, который раньше стоял между ними. До дюжины символов, не являющихся цифрами, покрывает всё это, не давая шаблону перескочить на другое число.

Шаблоны, которые разбивают цифры

Популярный дизайн размещает каждую цифру кода в отдельном блоке, чтобы это хорошо смотрелось на телефоне. В HTML это шесть ячеек таблицы или шесть <span>, и число нигде в исходном коде не встречается как шесть символов подряд:

что на самом деле содержит HTML-часть
<p>Your code is</p>
<table><tr>
  <td class="digit">4</td><td class="digit">8</td><td class="digit">1</td>
  <td class="digit">9</td><td class="digit">2</td><td class="digit">0</td>
</tr></table>

Регулярное выражение по «сырому» HTML не найдёт ничего. Решение — не более хитрое выражение, а сначала превратить HTML в текст, в строго заданном порядке:

  1. Замените каждый тег пробелом. Именно пробелом, а не пустотой — <td>4</td><td>8</td> должен стать 4 8, а не 48, слипшимся с тем, что шло дальше.
  2. Раскодируйте сущности. &amp;, &nbsp;, &#39;. Неразрывный пробел между двумя цифрами не будет пробелом для регулярного выражения, пока его не раскодировать.
  3. Схлопните пробелы, а затем сопоставляйте шаблон, допуская пробелы между цифрами. Для дизайна с блоками — code is\D{0,12}(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d) и склейте группы; для обычного шаблона хватит и простого выражения из предыдущего раздела.

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

Самое новое письмо не всегда оказывается нужным

Список писем в ящике здесь всегда возвращается от новых к старым, и messages[0] — то, что читают в большинстве черновых версий. Есть три ситуации, где это оказывается не тем письмом:

Два письма от одного действия
Регистрация отправляет приветственное письмо и письмо с кодом — в том порядке, в котором опустошается очередь отправителя. В половине случаев новее оказывается приветственное. Прежде чем что-либо брать, фильтруйте по теме или по отправителю.
Повторная отправка
Тест запросил код дважды — один раз случайно, один раз намеренно, — а сервер принимает только последний. Старое письмо всё ещё лежит в ящике, всё ещё совпадает с шаблоном и всё ещё разбирается в шесть цифр, которые уже недействительны.
Предыдущий прогон теста
Возможно, только если адрес был переиспользован, а этого не должно случаться никогда. Новый адрес на каждый прогон делает этот случай невозможным; если завести его нельзя, запасной вариант — снимок ящика ниже.

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

ждать письмо, которого не было до повторной отправки
// Remember what is already there, THEN trigger the resend, THEN wait for something new.
const before = new Set((await listMailbox(address)).messages.map(m => m.id));

await page.getByRole('button', { name: 'Resend code' }).click();

const fresh = await waitFor(address, m => !before.has(m.id) && /code/i.test(m.subject));

Коды, которые истекают прямо во время прогона

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

  • Запрашивайте код как можно позже. Инициируйте отправку прямо перед ожиданием, а не в шаге настройки, который выполняется, пока другие тесты стоят в очереди.
  • Держите дедлайн ожидания заметно меньше срока жизни кода. Шестидесятисекундный дедлайн для десятиминутного кода оставляет девять минут на то, чтобы его ввести. Десятиминутный дедлайн не оставляет ничего.
  • Никогда не сохраняйте код для другого теста. Коды не только недолговечны, но и одноразовы; общая фикстура, которая выдаёт один и тот же код, — это гонка между двумя тестами за одно число.

Готовые функции извлечения

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

Из shell, с помощью jq

shell
curl -sG "https://grabmail.io/api/v1/message/$ID" --data-urlencode "mailbox=$ADDR" \
| jq -r '[.text, .html] | map(select(. != null)) | join(" ") | gsub("<[^>]*>"; " ")' \
| grep -oiE 'code is[^0-9]{0,12}[0-9]{6}' | grep -oE '[0-9]{6}' | head -1

Python

extract.py
import html
import re

TAGS = re.compile(r"<[^>]+>")
CODE = re.compile(r"code is\D{0,12}(\d{6})", re.I)      # anchored on YOUR template's wording


def text_of(message: dict) -> str:
    """Both parts as plain text: tags out, entities decoded, whitespace folded."""
    raw = f"{message.get('text') or ''}\n{message.get('html') or ''}"
    return re.sub(r"\s+", " ", html.unescape(TAGS.sub(" ", raw)))


def code_from(message: dict, pattern: re.Pattern = CODE) -> str:
    hit = pattern.search(text_of(message))
    if not hit:
        raise AssertionError(f"no code in message {message['id']!r} ({message['subject']!r})")
    return hit.group(1)

TypeScript

extract.ts
export type Message = { id: string; subject: string; text: string | null; html: string | null };

const TAGS = /<[^>]+>/g;
const ENTITIES: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&nbsp;': ' ' };

/** Both parts as plain text: tags out, the common entities decoded, whitespace folded. */
export const textOf = (m: Message): string =>
  `${m.text ?? ''}\n${m.html ?? ''}`
    .replace(TAGS, ' ')
    .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, e => ENTITIES[e])
    .replace(/\s+/g, ' ');

/** Anchored on your own wording. A reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\D{0,12}(\d{6})/i): string {
  const hit = textOf(m).match(pattern);
  if (!hit) throw new Error(`no code in message ${m.id} ("${m.subject}")`);
  return hit[1];
}

JSON письма, который они читают, приходит из GET /api/v1/message/{id}, описанного в справочнике по API; ожидание, которое вообще даёт вам этот id, — в руководстве по сквозному тестированию, а также в виде готовых помощников для Playwright, Cypress, Python и Node.js.

Прежде чем считать задачу закрытой

  • Шаблон привязан к формулировке вашего письма и хранится рядом с самим письмом.
  • Поиск идёт по обеим частям как по тексту: теги превращены в пробелы, сущности раскодированы, пробелы схлопнуты.
  • Фильтр по теме или отправителю — чтобы приветственное письмо никогда не побеждало письмо с кодом.
  • Снимок ящика перед любой повторной отправкой, и после него принимаются только новые письма.
  • Дедлайн ожидания заметно меньше срока жизни кода, а отправка инициируется прямо перед ожиданием.
  • Сообщение о провале называет id письма и тему, по которым велся поиск.

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

Вопросы

Читать часть text или часть html?

Часть text, если она есть: она стабильна, и в ней нечего декодировать. Но всё равно ищите в обеих, как это делают готовые помощники, — тогда шаблон, отправляющий только HTML, всё равно сработает, а шаблон, отправляющий только текст, никогда не споткнётся о пустой HTML.

В моём коде есть буквы. Меняется ли шаблон?

Меняется только класс символов: ([A-Z0-9]{6}) или какой там алфавит использует отправитель, — при этом привязка к формулировке перед кодом остаётся. Добавьте флаг i, если регистр не гарантирован, и следите, чтобы класс символов заодно не совпал с английским словом сразу после привязки.

А как насчёт magic-ссылок вместо кодов?

Та же дисциплина, другой шаблон: сверяйте URL по известному фрагменту пути — /confirm/, /auth/magic/, — а не по принципу «первая попавшаяся ссылка», потому что в транзакционном письме обычно штук пять ссылок, и нужная редко оказывается первой. Прежде чем переходить по ней, раскодируйте &amp;.

Как долго письмо доступно для чтения?

5 дней с момента доставки, независимо от того, прочитано оно или нет. Это намного дольше, чем действителен любой код, так что тесту никогда не нужно спешить с чтением — только со вводом.

Можно ли получить код, не опрашивая ящик?

Через REST — нет: вы опрашиваете раз в секунду с дедлайном, это задокументированный ритм, который никогда не ограничивается искусственно. Через MCP есть инструмент wait_for_message, который держит вызов открытым, пока не придёт письмо, — именно такая форма и нужна AI-агенту.

Нужен ли API-ключ?

Нет. Публичные домены не требуют ни ключа, ни аккаунта, ни заголовка. Bearer-токен используется только для платного пула доменов, которые держат подальше от чёрных списков одноразовой почты, а код извлечения в обоих случаях одинаковый.

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

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

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

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

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